diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cfd56ed..05d2bfcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9'] + python-version: ['3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 @@ -28,17 +28,16 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pre-commit codecov - - - name: Install module - run: python -m pip install .["test"] + python -m pip install -e .["test"] - name: Run pre-commit if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} - run: pre-commit run --all-files + run: | + python -m pip install pre-commit + pre-commit run --all-files - name: Run tests - run: python -m pytest --cov rio_tiler --cov-report xml --cov-report term-missing --benchmark-skip + run: python -m pytest --cov rio_tiler --cov-report xml --cov-report term-missing --benchmark-skip -s -vv - name: Upload Results if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} @@ -73,20 +72,20 @@ jobs: - name: Run Benchmark run: python -m pytest --benchmark-only --benchmark-autosave --benchmark-columns 'min, max, mean, median' --benchmark-sort 'min' --benchmark-json output.json - - name: Store and Compare benchmark result - uses: benchmark-action/github-action-benchmark@v1 - with: - name: rio-tiler Benchmarks - tool: 'pytest' - output-file-path: output.json - alert-threshold: '130%' - comment-on-alert: true - fail-on-alert: true - # GitHub API token to make a commit comment - github-token: ${{ secrets.GITHUB_TOKEN }} - # Make a commit on `gh-pages` only if master - auto-push: ${{ github.ref == 'refs/heads/master' }} - benchmark-data-dir-path: benchmarks + # - name: Store and Compare benchmark result + # uses: benchmark-action/github-action-benchmark@v1 + # with: + # name: rio-tiler Benchmarks + # tool: 'pytest' + # output-file-path: output.json + # alert-threshold: '130%' + # comment-on-alert: true + # fail-on-alert: true + # # GitHub API token to make a commit comment + # github-token: ${{ secrets.GITHUB_TOKEN }} + # # Make a commit on `gh-pages` only if master + # auto-push: ${{ github.ref == 'refs/heads/master' }} + # benchmark-data-dir-path: benchmarks publish: needs: [tests] diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 97bdf800..336a99d1 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -44,8 +44,9 @@ jobs: rio_tiler.expression \ rio_tiler.models \ rio_tiler.io.base \ - rio_tiler.io.cogeo \ + rio_tiler.io.rasterio \ rio_tiler.io.stac \ + rio_tiler.io.xarray \ rio_tiler.mosaic.methods.base \ rio_tiler.mosaic.methods.defaults \ rio_tiler.mosaic.reader \ diff --git a/.gitignore b/.gitignore index 5f96ea0b..87350fa2 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,4 @@ tests/benchmarks/data/* tests/fixtures/mask* .vscode/settings.json -docs/api* +docs/src/api/* diff --git a/CHANGES.md b/CHANGES.md index e207d274..5615dfdc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,162 @@ +# 4.0.0 (TBD) + +* add python 3.10 support +* add `apply_expression` method in `rio_tiler.models.ImageData` class +* update `rio-tiler.reader.read/part` to avoid using WarpedVRT when no *reprojection* or *nodata override* is needed +* add `rio_tiler.io.rasterio.ImageReader` to work either with Non-geo or Geo images in a Non-geo manner (a.k.a: in the pixel coordinates system) + ```python + with ImageReader("image.jpg") as src: + im = src.part((0, 100, 100, 0)) + + with ImageReader("image.jpg") as src: + im = src.tile(0, 0, src.maxzoom) + print(im.bounds) + + >>> BoundingBox(left=0.0, bottom=256.0, right=256.0, top=0.0) + ``` + +* add `rio_tiler.io.xarray.XarrayReader` to work with `xarray.DataArray` + ```python + import xarray + from rio_tiler.io import XarrayReader + + with xarray.open_dataset( + "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", + engine="zarr", + decode_coords="all" + ) as src: + ds = src["analysed_sst"][:1] + ds.rio.write_crs("epsg:4326", inplace=True) + + with XarrayReader(ds) as dst: + img = dst.tile(1, 1, 2) + ``` + note: `xarray` and `rioxarray` optional dependencies are needed for the reader + +**breaking changes** + +* remove python 3.7 support +* update rasterio requirement to `>=1.3` to allow python 3.10 support +* rename `rio_tiler.io.cogeo` to `rio_tiler.io.rasterio` +* rename `COGReader` to `Reader`. We added `rio_tiler.io.COGReader` alias to `rio_tiler.io.Reader` backwards compatibility + ```python + # before + from rio_tiler.io import COGReader + from rio_tiler.io.cogeo import COGReader + + # now + from rio_tiler.io import Reader + from rio_tiler.io.rasterio import Reader + ``` + +* `rio_tiler.readers.read()`, `rio_tiler.readers.part()`, `rio_tiler.readers.preview()` now return a ImageData object +* remove `minzoom` and `maxzoom` attribute in `rio_tiler.io.SpatialMixin` base class +* remove `minzoom` and `maxzoom` attribute in `rio_tiler.io.Reader` (now defined as properties) +* use `b` prefix for band names in `rio_tiler.models.ImageData` class (and in rio-tiler's readers) + ```python + # before + with COGReader("cog.tif") as cog: + img = cog.read() + print(cog.band_names) + >>> ["1", "2", "3"] + + print(cog.info().band_metadata) + >>> [("1", {}), ("2", {}), ("3", {})] + + print(cog.info().band_descriptions) + >>> [("1", ""), ("2", ""), ("3", "")] + + print(list(cog.statistics())) + >>> ["1", "2", "3"] + + # now + with Reader("cog.tif") as cog: + img = cog.read() + print(img.band_names) + >>> ["b1", "b2", "b3"] + + print(cog.info().band_metadata) + >>> [("b1", {}), ("b2", {}), ("b3", {})] + + print(cog.info().band_descriptions) + >>> [("b1", ""), ("b2", ""), ("b3", "")] + + print(list(cog.statistics())) + >>> ["b1", "b2", "b3"] + + with STACReader("stac.json") as stac: + print(stac.tile(701, 102, 8, assets=("green", "red")).band_names) + >>> ["green_b1", "red_b1"] + ``` + +* depreciate `asset_expression` in MultiBaseReader. Use of expression is now possible +* `expression` for MultiBaseReader must be in form of `{asset}_b{index}` + + ```python + # before + with STACReader("stac.json") as stac: + stac.tile(701, 102, 8, expression="green/red") + + # now + with STACReader("stac.json") as stac: + stac.tile(701, 102, 8, expression="green_b1/red_b1") + ``` + +* `rio_tiler.reader.point()` (and all Reader's point methods) now return a **rio_tiler.models.PointData** object + + ```python + # before + with rasterio.open("cog.tif") as src:: + v = rio_tiler.reader.point(10.20, -42.0) + print(v) + >>> [0, 0, 0] + + with COGReader("cog.tif") as cog: + print(cog.point(10.20, -42.0)) + >>> [0, 0, 0] + + # now + with rasterio.open("cog.tif") as src:: + v = rio_tiler.reader.point(src, (10.20, -42)) + print(v) + >>> PointData( + data=array([3744], dtype=uint16), + mask=array([255], dtype=uint8), + band_names=['b1'], + coordinates=(10.20, -42), + crs=CRS.from_epsg(4326), + assets=['cog.tif'], + metadata={} + ) + + with Reader("cog.tif") as cog: + print(cog.point(10.20, -42.0)) + >>> PointData( + data=array([3744], dtype=uint16), + mask=array([255], dtype=uint8), + band_names=['b1'], + coordinates=(10.20, -42), + crs=CRS.from_epsg(4326), + assets=['cog.tif'], + metadata={} + ) + ``` + +* deleted `rio_tiler.reader.preview` function and updated `rio_tiler.reader.read` to allow width/height/max_size options +* reordered keyword options in all `rio_tiler.reader` function for consistency +* removed `AlphaBandWarning` warning when automatically excluding alpha band from data +* remove `nodata`, `unscale`, `resampling_method`, `vrt_options` and `post_process` options to `Reader` init method and replaced with `options` + ```python + # before + with COGReader("cog.tif", nodata=1, resampling_method="bilinear") as cog: + data = cog.preview() + + # now + with Reader(COGEO, options={"nodata": 1, "resampling_method": "bilinear"}) as cog: + data = cog.preview() + ``` + # 3.1.6 (2022-07-22) * Hide `NotGeoreferencedWarning` warnings in `utils.render` and `utils.resize_array` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 602de440..f2c1e08d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,8 +56,9 @@ pdocs as_markdown \ rio_tiler.expression \ rio_tiler.models \ rio_tiler.io.base \ - rio_tiler.io.cogeo \ + rio_tiler.io.rasterio \ rio_tiler.io.stac \ + rio_tiler.io.xarray \ rio_tiler.mosaic.methods.base \ rio_tiler.mosaic.methods.defaults \ rio_tiler.mosaic.reader \ diff --git a/README.md b/README.md index a86573e9..3d450bdf 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,16 @@ data and metadata from any raster source supported by Rasterio/GDAL. This includes local and remote files via HTTP, AWS S3, Google Cloud Storage, etc. -At the low level, `rio-tiler` is *just* a wrapper around the [rasterio.vrt.WarpedVRT](https://github.com/rasterio/rasterio/blob/5b76d05fb374e64602166d6cd880c38424fad39b/rasterio/vrt.py#L15) class, which can be useful for doing re-projection and/or property overriding (e.g nodata value). +At the low level, `rio-tiler` is *just* a wrapper around the [rasterio](https://github.com/rasterio/rasterio) and [GDAL](https://github.com/osgeo/gdal) libraries. ## Features - Read any dataset supported by GDAL/Rasterio ```python - from rio_tiler.io import COGReader + from rio_tiler.io import Reader - with COGReader("my.tif") as image: + with Reader("my.tif") as image: print(image.dataset) # rasterio opened dataset img = image.read() # similar to rasterio.open("my.tif").read() but returns a rio_tiler.models.ImageData object ``` @@ -64,9 +64,9 @@ At the low level, `rio-tiler` is *just* a wrapper around the [rasterio.vrt.Warpe - User friendly `tile`, `part`, `feature`, `point` reading methods ```python - from rio_tiler.io import COGReader + from rio_tiler.io import Reader - with COGReader("my.tif") as image: + with Reader("my.tif") as image: img = image.tile(x, y, z) # read mercator tile z-x-y img = image.part(bbox) # read the data intersecting a bounding box img = image.feature(geojson_feature) # read the data intersecting a geojson feature @@ -76,9 +76,9 @@ At the low level, `rio-tiler` is *just* a wrapper around the [rasterio.vrt.Warpe - Enable property assignment (e.g nodata) on data reading ```python - from rio_tiler.io import COGReader + from rio_tiler.io import Reader - with COGReader("my.tif") as image: + with Reader("my.tif") as image: img = image.tile(x, y, z, nodata=-9999) # read mercator tile z-x-y ``` @@ -107,14 +107,46 @@ At the low level, `rio-tiler` is *just* a wrapper around the [rasterio.vrt.Warpe ) ``` +- [Xarray](https://xarray.dev) support **(>=4.0)** + + ```python + import xarray + from rio_tiler.io import XarrayReader + + ds = xarray.open_dataset( + "https://pangeo.blob.core.windows.net/pangeo-public/daymet-rio-tiler/na-wgs84.zarr/", + engine="zarr", + decode_coords="all", + consolidated=True, + ) + da = ds["tmax"] + with XarrayReader(da) as dst: + print(dst.info()) + img = dst.tile(1, 1, 2) + ``` + *Note: The XarrayReader needs optional dependencies to be installed `pip install rio-tiler["xarray"]`.* + +- Non-Geo Image support **(>=4.0)** + + ```python + from rio_tiler.io import ImageReader + + with ImageReader("image.jpeg") as src: + im = src.tile(0, 0, src.maxzoom) # read top-left `tile` + im = src.part((0, 100, 100, 0)) # read top-left 100x100 pixels + pt = src.point(0, 0) # read pixel value + ``` + + *Note: `ImageReader` is also compatible with proper geo-referenced raster datasets.* + - [Mosaic](https://cogeotiff.github.io/rio-tiler/mosaic/) (merging or stacking) ```python - from rio_tiler.io import COGReader + from rio_tiler.io import Reader from rio_tiler.mosaic import mosaic_reader def reader(file, x, y, z, **kwargs): - with COGReader(file) as image: + with Reader(file) as image: return image.tile(x, y, z, **kwargs) img, assets = mosaic_reader(["image1.tif", "image2.tif"], reader, x, y, z) @@ -124,11 +156,11 @@ At the low level, `rio-tiler` is *just* a wrapper around the [rasterio.vrt.Warpe ```python import morecantile - from rio_tiler.io import COGReader + from rio_tiler.io import Reader # Use EPSG:4326 (WGS84) grid wgs84_grid = morecantile.tms.get("WorldCRS84Quad") - with COGReader("my.tif", tms=wgs84_grid) as cog: + with Reader("my.tif", tms=wgs84_grid) as cog: img = cog.tile(1, 1, 1) ``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 0289ac90..a269cc9d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -51,8 +51,9 @@ nav: - rio_tiler.models: api/rio_tiler/models.md - rio_tiler.io: - rio_tiler.io.base: api/rio_tiler/io/base.md - - rio_tiler.io.cogeo: api/rio_tiler/io/cogeo.md + - rio_tiler.io.rasterio: api/rio_tiler/io/rasterio.md - rio_tiler.io.stac: api/rio_tiler/io/stac.md + - rio_tiler.io.xarray: api/rio_tiler/io/xarray.md - rio_tiler.mosaic: - rio_tiler.mosaic.reader: api/rio_tiler/mosaic/reader.md - rio_tiler.mosaic.methods: @@ -64,6 +65,7 @@ nav: - rio_tiler.utils: api/rio_tiler/utils.md - Migration from v1.0 to v2.0: 'v2_migration.md' - Migration from v2.0 to v3.0: 'v3_migration.md' + - Migration from v3.0 to v4.0: 'v4_migration.md' - Development - Contributing: 'contributing.md' - Release Notes: 'release-notes.md' diff --git a/docs/src/advanced/custom_readers.md b/docs/src/advanced/custom_readers.md index b105043d..2507909d 100644 --- a/docs/src/advanced/custom_readers.md +++ b/docs/src/advanced/custom_readers.md @@ -1,7 +1,7 @@ `rio-tiler` provides multiple [abstract base classes](https://docs.python.org/3.7/library/abc.html) from which it derives its -main readers: [`COGReader`](../readers.md#cogreader) and +main readers: [`Reader`](../readers.md#reader) and [`STACReader`](../readers.md#stacreader). You can also use these classes to build custom readers. @@ -16,8 +16,6 @@ Main `rio_tiler.io` Abstract Base Class. - **input**: Input - **tms**: The TileMatrixSet define which default projection and map grid the reader uses. Defaults to WebMercatorQuad. -- **minzoom**: Dataset's minzoom. Not in the `__init__` method. -- **maxzoom**: Dataset's maxzoom. Not in the `__init__` method. - **bounds**: Dataset's bounding box. Not in the `__init__` method. - **crs**: dataset's crs. Not in the `__init__` method. - **geographic_crs**: CRS to use as geographic coordinate system. Defaults to WGS84. Not in the `__init__` method. @@ -45,13 +43,7 @@ Abstract methods, are method that **HAVE TO** be implemented in the child class. - **point**: reads pixel value for a specific point (`List`) - **feature**: reads data for a geojson feature (`rio_tiler.models.ImageData`) -Example: [`COGReader`](../readers.md#cogreader) - -### **AsyncBaseReader** - -Equivalent of `BaseReader` for async-ready readers (e.g [aiocogeo](https://github.com/geospatial-jeff/aiocogeo)). The `AsyncBaseReader` has the same attributes/properties/methods as the `BaseReader`. - -see example of reader built using `AsyncBaseReader`: https://github.com/cogeotiff/rio-tiler/blob/832ecbd97f560c2764818bca30ca95ef25408527/tests/test_io_async.py#L49 +Example: [`Reader`](../readers.md#reader) ### **MultiBaseReader** @@ -69,7 +61,7 @@ from typing import Dict, Type import attr from morecantile import TileMatrixSet from rio_tiler.io.base import MultiBaseReader -from rio_tiler.io import COGReader, BaseReader +from rio_tiler.io import Reader, BaseReader from rio_tiler.constants import WEB_MERCATOR_TMS from rio_tiler.models import Info @@ -81,7 +73,7 @@ class AssetFileReader(MultiBaseReader): # because we add another attribute (prefix) we need to # re-specify the other attribute for the class - reader: Type[BaseReader] = attr.ib(default=COGReader) + reader: Type[BaseReader] = attr.ib(default=Reader) reader_options: Dict = attr.ib(factory=dict) tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) @@ -111,30 +103,30 @@ class AssetFileReader(MultiBaseReader): # we have a directoty with "scene_b1.tif", "scene_b2.tif" with AssetFileReader(input="my_dir/", prefix="scene_") as cr: print(cr.assets) - >>> ['b1', 'b2'] + >>> ['band1', 'band2'] - info = cr.info(assets=("b1", "b2")) + info = cr.info(assets=("band1", "band2")) # MultiBaseReader returns a Dict assert isinstance(info, dict) print(list(info)) - >>> ['b1', 'b2'] + >>> ['band1', 'band2'] - assert isinstance(info["b1"], Info) - print(info["b1"].json(exclude_none=True)) + assert isinstance(info["band1"], Info) + print(info["band1"].json(exclude_none=True)) >>> { 'bounds': [-11.979244865430259, 24.296321392464325, -10.874546803397614, 25.304623891542263], 'minzoom': 7, 'maxzoom': 9, - 'band_metadata': [('1', {})], - 'band_descriptions': [('1', '')], + 'band_metadata': [('b1', {})], + 'band_descriptions': [('b1', '')], 'dtype': 'uint16', 'nodata_type': 'Nodata', 'colorinterp': ['gray'] } - img = cr.tile(238, 218, 9, assets=("b1", "b2")) + img = cr.tile(238, 218, 9, assets=("band1", "band2")) print(img.assets) - >>> ['my_dir/scene_b1.tif', 'my_dir/scene_b2.tif'] + >>> ['my_dir/scene_band1.tif', 'my_dir/scene_band2.tif'] # Each assets have 1 bands, so when combining each img we get a (2, 256, 256) array. print(img.data.shape) @@ -199,24 +191,24 @@ class BandFileReader(MultiBandReader): # we have a directoty with "scene_b1.tif", "scene_b2.tif" with BandFileReader(input="my_dir/", prefix="scene_") as cr: print(cr.bands) - >>> ['b1', 'b2'] + >>> ['band1', 'band2'] - print(cr.info(bands=("b1", "b2")).json(exclude_none=True)) + print(cr.info(bands=("band1", "band2")).json(exclude_none=True)) >>> { 'bounds': [-11.979244865430259, 24.296321392464325, -10.874546803397614, 25.304623891542263], 'minzoom': 7, 'maxzoom': 9, - 'band_metadata': [('b1', {}), ('b2', {})], - 'band_descriptions': [('b1', ''), ('b2', '')], + 'band_metadata': [('band1', {}), ('band2', {})], + 'band_descriptions': [('band1', ''), ('band2', '')], 'dtype': 'uint16', 'nodata_type': 'Nodata', 'colorinterp': ['gray', 'gray'] } - img = cr.tile(238, 218, 9, bands=("b1", "b2")) + img = cr.tile(238, 218, 9, bands=("band1", "band2")) print(img.assets) - >>> ['my_dir/scene_b1.tif', 'my_dir/scene_b2.tif'] + >>> ['my_dir/scene_band1.tif', 'my_dir/scene_band2.tif'] print(img.data.shape) >>> (2, 256, 256) @@ -227,7 +219,7 @@ Note: [`rio-tiler-pds`][rio-tiler-pds] readers are built using the `MultiBandRea [rio-tiler-pds]: https://github.com/cogeotiff/rio-tiler-pds -## Custom COGReader subclass +## Custom Reader subclass The example :point_down: was created as a response to https://github.com/developmentseed/titiler/discussions/235. In short, the user needed a way to keep metadata information from an asset within a STAC item. @@ -239,12 +231,12 @@ But rio-tiler has been designed to be easily customizable. import attr from rasterio.io import DatasetReader from rio_tiler.io.stac import fetch, _to_pystac_item -from rio_tiler.io import COGReader +from rio_tiler.io import Reader import pystac @attr.s -class CustomSTACReader(COGReader): - """Custom COG Reader support.""" +class CustomSTACReader(Reader): + """Custom Reader support.""" # This will keep the STAC item info within the instance item: pystac.Item = attr.ib(default=None, init=False) @@ -279,7 +271,7 @@ In this `CustomSTACReader`, we are using a custom path `schema` in form of `{ite 1. Parse the input path to get the STAC url and asset name 2. Fetch and parse the STAC item 3. Construct a new `input` using the asset full url. -4. Fall back to the regular `COGReader` initialization (using `super().__attrs_post_init__()`) +4. Fall back to the regular `Reader` initialization (using `super().__attrs_post_init__()`) ## Simple Reader @@ -298,7 +290,7 @@ from morecantile import TileMatrixSet from rio_tiler.constants import BBox, WEB_MERCATOR_TMS @attr.s -class Reader(BaseReader): +class SimpleReader(BaseReader): input: DatasetReader = attr.ib() @@ -355,6 +347,6 @@ class Reader(BaseReader): ) with rasterio.open("file.tif") as src: - with Reader(src) as cog: + with SimpleReader(src) as cog: img = cog.tile(1, 1, 1) ``` diff --git a/docs/src/advanced/dynamic_tiler.md b/docs/src/advanced/dynamic_tiler.md index 8e6d5d2a..896de250 100644 --- a/docs/src/advanced/dynamic_tiler.md +++ b/docs/src/advanced/dynamic_tiler.md @@ -20,7 +20,7 @@ your own API. ### Requirements -- `rio-tiler ~= 3.0` +- `rio-tiler ~= 4.0` - `fastapi` - `uvicorn` @@ -49,7 +49,7 @@ from starlette.requests import Request from starlette.responses import Response from rio_tiler.profiles import img_profiles -from rio_tiler.io import COGReader +from rio_tiler.io import Reader app = FastAPI( @@ -75,7 +75,7 @@ def tile( url: str = Query(..., description="Cloud Optimized GeoTIFF URL."), ): """Handle tile requests.""" - with COGReader(url) as cog: + with Reader(url) as cog: img = cog.tile(x, y, z) content = img.render(img_format="PNG", **img_profiles.get("png")) return Response(content, media_type="image/png") @@ -90,7 +90,7 @@ def tilejson( tile_url = request.url_for("tile", {"z": "{z}", "x": "{x}", "y": "{y}"}) tile_url = f"{tile_url}?url={url}" - with COGReader(url) as cog: + with Reader(url) as cog: return { "bounds": cog.geographic_bounds, "minzoom": cog.minzoom, diff --git a/docs/src/advanced/feature.md b/docs/src/advanced/feature.md index 391151e5..98877862 100644 --- a/docs/src/advanced/feature.md +++ b/docs/src/advanced/feature.md @@ -1,12 +1,12 @@ ![](https://user-images.githubusercontent.com/10407788/105767632-3f959e80-5f29-11eb-9331-969f3f53111e.png) -Starting with `rio-tiler` v2, a `.feature()` method exists on `rio-tiler`'s readers (e.g `COGReader`) which enables data reading for GeoJSON defined (polygon or multipolygon) shapes. +Starting with `rio-tiler` v2, a `.feature()` method exists on `rio-tiler`'s readers (e.g `Reader`) which enables data reading for GeoJSON defined (polygon or multipolygon) shapes. ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData -with COGReader("my-tif.tif") as cog: +with Reader("my-tif.tif") as cog: # Read data for a given geojson polygon img: ImageData = cog.feature(geojson_feature, max_size=1024) # we limit the max_size to 1024 ``` @@ -15,12 +15,12 @@ Under the hood, the `.feature` method uses `GDALWarpVRT`'s `cutline` option and the `.part()` method. The below process is roughly what `.feature` does for you. ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.utils import create_cutline from rasterio.features import bounds as featureBounds -# Use COGReader to open and read the dataset -with COGReader("my_tif.tif") as cog: +# Use Reader to open and read the dataset +with Reader("my_tif.tif") as cog: # Create WTT Cutline cutline = create_cutline(cog.dataset, feat, geometry_crs="epsg:4326") @@ -36,8 +36,8 @@ Another interesting fact about the `cutline` option is that it can be used with ```python bbox = featureBounds(feat) -# Use COGReader to open and read the dataset -with COGReader("my_tif.tif") as cog: +# Use Reader to open and read the dataset +with Reader("my_tif.tif") as cog: # Create WTT Cutline cutline = create_cutline(cog.dataset, feat, geometry_crs="epsg:4326") diff --git a/docs/src/advanced/tms.md b/docs/src/advanced/tms.md index 8d4e6b72..0e29d34a 100644 --- a/docs/src/advanced/tms.md +++ b/docs/src/advanced/tms.md @@ -6,12 +6,12 @@ Starting with rio-tiler 2.0, we replaced [`mercantile`][mercantile] with [_`more ```python import morecantile -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rasterio.crs import CRS from pyproj import CRS as projCRS # By default we use WebMercator TMS -with COGReader("my.tif") as cog: +with Reader("my.tif") as cog: img = cog.tile(1, 1, 1) assert img.crs == CRS.from_epsg(3857) # default image output is the TMS crs (WebMercator) @@ -34,7 +34,7 @@ for name, tms in morecantile.tms.tms.items(): # Use EPSG:4326 (WGS84) grid wgs84_grid = morecantile.tms.get("WorldCRS84Quad") -with COGReader("my.tif", tms=wgs84_grid) as cog: +with Reader("my.tif", tms=wgs84_grid) as cog: img = cog.tile(1, 1, 1) assert img.crs == CRS.from_epsg(4326) @@ -43,7 +43,7 @@ extent = [-948.75, -543592.47, 5817.41, -3333128.95] # From https:///epsg.io/30 epsg3031TMS = morecantile.TileMatrixSet.custom( extent, projCRS.from_epsg(3031), identifier="MyCustomTmsEPSG3031" ) -with COGReader("my.tif", tms=epsg3031TMS) as cog: +with Reader("my.tif", tms=epsg3031TMS) as cog: img = cog.tile(1, 1, 1) assert img.crs == CRS.from_epsg(3031) ``` diff --git a/docs/src/advanced/zonal_stats.md b/docs/src/advanced/zonal_stats.md index 2ff5e911..39e33eb4 100644 --- a/docs/src/advanced/zonal_stats.md +++ b/docs/src/advanced/zonal_stats.md @@ -12,8 +12,8 @@ from rio_tiler.models import BandStatistics from geojson_pydantic.features import Feature, FeatureCollection from geojson_pydantic.geometries import Polygon -class COGReader(io.COGReader): - """Custom COGReader with zonal_statistics method.""" +class Reader(io.Reader): + """Custom Reader with zonal_statistics method.""" def zonal_statistics( self, @@ -49,7 +49,7 @@ class COGReader(io.COGReader): geojson = FeatureCollection(features=[geojson]) for feature in geojson: - # Get data overlapping with the feature (using COGReader.feature method) + # Get data overlapping with the feature (using Reader.feature method) data = self.feature( feature.dict(exclude_none=True), max_size=max_size, diff --git a/docs/src/colormap.md b/docs/src/colormap.md index 9799c63b..ffaf69e0 100644 --- a/docs/src/colormap.md +++ b/docs/src/colormap.md @@ -8,26 +8,25 @@ to `rio_tiler.utils.render`: ```python from rio_tiler.colormap import cmap -from rio_tiler.io import COGReader +from rio_tiler.io import Reader # Get Colormap # You can list available colormap names with `cmap.list()` cm = cmap.get("cfastie") -with COGReader( - "s3://landsat-pds/c1/L8/015/029/LC08_L1GT_015029_20200119_20200119_01_RT/LC08_L1GT_015029_20200119_20200119_01_RT_B8.TIF", - nodata=0, -) as cog: - img = cog.tile(150, 187, 9) +with Reader( + "https://sentinel-cogs.s3.amazonaws.com/sentinel-s2-l2a-cogs/29/R/KH/2020/2/S2A_29RKH_20200219_0_L2A/B01.tif", +) as src: + img = src.tile(239, 220, 9) # Rescale the data linearly from 0-10000 to 0-255 - image_rescale = img.post_process( + img.rescale( in_range=((0, 10000),), out_range=((0, 255),) ) # Apply colormap and create a PNG buffer - buff = image_rescale.render(colormap=cm) # this returns a buffer (PNG by default) + buff = img.render(colormap=cm) # this returns a buffer (PNG by default) ``` The `render` method accept colormap in form of: diff --git a/docs/src/examples/Using-nonEarth-dataset.ipynb b/docs/src/examples/Using-nonEarth-dataset.ipynb index 070abace..48e287cf 100644 --- a/docs/src/examples/Using-nonEarth-dataset.ipynb +++ b/docs/src/examples/Using-nonEarth-dataset.ipynb @@ -21,7 +21,7 @@ "# Requirements\n", "\n", "To be able to run this notebook you'll need the following requirements:\n", - "- rio-tiler~= 3.0\n", + "- rio-tiler~=4.0\n", "- ipyleaflet\n", "- matplotlib" ] @@ -44,14 +44,14 @@ "outputs": [], "source": [ "%pylab inline\n", - "from rio_tiler.io import COGReader\n", + "from rio_tiler.io import Reader\n", "\n", "# In order to fully work, we'll need to build a custom TileMatrixSet\n", "from morecantile import TileMatrixSet\n", "from pyproj import CRS\n", "\n", "# For this DEMO we will use this file\n", - "src_path = \"https://asc-jupiter.s3-us-west-2.amazonaws.com/europa/galileo_voyager/controlled_mosaics/11ESCOLORS01-02_GalileoSSI_Equi-cog.tif\"" + "src_path = \"https://raw.githubusercontent.com/cogeotiff/rio-tiler/master/tests/fixtures/cog_nonearth.tif\"" ] }, { @@ -60,12 +60,12 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "# Let's first try with default\n", - "# We should see 2 different warnings here\n", - "# - UserWarning: Cannot dertermine min/max zoom based on dataset informations: We cannot get default Zooms in WebMercator projection\n", + "# We should see 3 different warnings here\n", "# - UserWarning: Cannot dertermine bounds in WGS84: There is no existing transformation to WGS84\n", - "with COGReader(src_path) as cog:\n", + "# - UserWarning: Cannot dertermine minzoom based on dataset informations: We cannot get default Zooms in WebMercator projection\n", + "# - UserWarning: Cannot dertermine maxzoom based on dataset informations: We cannot get default Zooms in WebMercator projection\n", + "with Reader(src_path) as cog:\n", " print(cog.info().json())" ] }, @@ -75,7 +75,6 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "# Create a CUSTOM TMS using the europa ESRI:104915 projection\n", "europa_crs = CRS.from_authority(\"ESRI\", 104915)\n", "europa_tms = TileMatrixSet.custom(\n", @@ -85,8 +84,8 @@ "# Use Custom TMS instead of Web Mercator\n", "# We should see 2 different warnings here\n", "# - UserWarning: Could not create coordinate Transformer from input CRS to WGS84: This is from morecantile. It means some methods won't be available but we can ignore. \n", - "# - UserWarning: Cannot dertermine bounds in WGS84: Same as before. the `cog.geographic` property will return default (-180.0, -90.0, 180.0, 90.0)\n", - "with COGReader(src_path, tms=europa_tms) as cog:\n", + "# - UserWarning: Cannot dertermine bounds in WGS84: Same as before. the `cog.geographic_bounds` property will return default (-180.0, -90.0, 180.0, 90.0)\n", + "with Reader(src_path, tms=europa_tms) as cog:\n", " print(cog.info().json())\n", " img = cog.preview()\n", " imshow(img.data_as_image())" @@ -95,20 +94,22 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": false + }, "outputs": [], "source": [ "# Read a Tile\n", "from rasterio.warp import transform_bounds\n", "\n", - "with COGReader(src_path, tms=europa_tms) as cog:\n", + "with Reader(src_path, tms=europa_tms) as cog:\n", " # get dataset bounds in TMS's CRS projection\n", " bounds_in_tms = transform_bounds(cog.crs, europa_tms.rasterio_crs, *cog.bounds)\n", " tile = cog.tms._tile(bounds_in_tms[0], bounds_in_tms[1], cog.minzoom)\n", " print(tile)\n", "\n", " img = cog.tile(tile.x, tile.y, tile.z)\n", - " imshow(img.data_as_image())\n" + " imshow(img.data_as_image())" ] }, { @@ -150,7 +151,7 @@ "from tornado.httpserver import HTTPServer\n", "from tornado.concurrent import run_on_executor\n", "\n", - "from rio_tiler.io import COGReader\n", + "from rio_tiler.io import Reader\n", "from rio_tiler.errors import TileOutsideBounds\n", "from rio_tiler.profiles import img_profiles\n", "\n", @@ -195,7 +196,7 @@ " def _get_tile(self, z, x, y):\n", "\n", " try:\n", - " with COGReader(self.url, tms=europa_tms) as cog:\n", + " with Reader(self.url, tms=europa_tms) as cog:\n", " data = cog.tile(x, y, z)\n", " except TileOutsideBounds:\n", " raise web.HTTPError(404)\n", @@ -267,7 +268,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -281,7 +282,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/docs/src/examples/Using-rio-tiler-STACReader.ipynb b/docs/src/examples/Using-rio-tiler-STACReader.ipynb index 43169315..6354400e 100644 --- a/docs/src/examples/Using-rio-tiler-STACReader.ipynb +++ b/docs/src/examples/Using-rio-tiler-STACReader.ipynb @@ -53,7 +53,7 @@ "source": [ "from rio_tiler.io import STACReader\n", "from rio_tiler.profiles import img_profiles\n", - "from rio_tiler.models import ImageData, Metadata" + "from rio_tiler.models import ImageData" ] }, { @@ -191,10 +191,10 @@ "source": [ "fig, axs = plt.subplots(1, 4, sharey=True, tight_layout=True, dpi=150)\n", "\n", - "axs[0].plot(meta[\"B01\"][\"1\"].histogram[1][0:-1], meta[\"B01\"][\"1\"].histogram[0])\n", - "axs[1].plot(meta[\"B02\"][\"1\"].histogram[1][0:-1], meta[\"B02\"][\"1\"].histogram[0])\n", - "axs[2].plot(meta[\"B03\"][\"1\"].histogram[1][0:-1], meta[\"B03\"][\"1\"].histogram[0])\n", - "axs[3].plot(meta[\"B04\"][\"1\"].histogram[1][0:-1], meta[\"B04\"][\"1\"].histogram[0])" + "axs[0].plot(meta[\"B01\"][\"b1\"].histogram[1][0:-1], meta[\"B01\"][\"b1\"].histogram[0])\n", + "axs[1].plot(meta[\"B02\"][\"b1\"].histogram[1][0:-1], meta[\"B02\"][\"b1\"].histogram[0])\n", + "axs[2].plot(meta[\"B03\"][\"b1\"].histogram[1][0:-1], meta[\"B03\"][\"b1\"].histogram[0])\n", + "axs[3].plot(meta[\"B04\"][\"b1\"].histogram[1][0:-1], meta[\"B04\"][\"b1\"].histogram[0])" ] }, { @@ -256,11 +256,12 @@ "outputs": [], "source": [ "# The sentinel data is stored as UInt16, we need to do some data rescaling to display data from 0 to 255\n", - "print(img.data.min(), img.data.max())\n", + "print(img.data.min(), img.data.max())x\n", + "\n", + "img.rescale(in_range=((0, 10000),))\n", + "print(img.min(), img.max())\n", "\n", - "image = img.post_process(in_range=((0, 10000),))\n", - "image = image.data_as_image()\n", - "print(image.min(), image.max())\n", + "image = img.data_as_image()\n", "imshow(image)" ] }, @@ -279,14 +280,16 @@ "source": [ "with STACReader(src_path) as stac:\n", " # By default `preview()` will return an array with its longest dimension lower or equal to 1024px\n", - " img = stac.preview(expression=\"(B08-B04)/(B08+B04)\", max_size=256)\n", + " img = stac.preview(expression=\"(B08_b1-B04_b1)/(B08_b1+B04_b1)\", max_size=256)\n", " print(img.data.shape)\n", " # learn more about the ImageData model https://cogeotiff.github.io/rio-tiler/models/#imagedata\n", " assert isinstance(img, ImageData)\n", "\n", "# NDVI data range should be between -1 and 1\n", - "image = img.post_process(in_range=((-1,1),))\n", - "image = image.data_as_image()\n", + "print(img.data.min(), img.data.max())\n", + "\n", + "img.rescale(in_range=((-1,1),))\n", + "image = img.data_as_image()\n", "imshow(image)" ] }, @@ -303,7 +306,8 @@ "hash": "e5a596c8625da0593f23bdd5ea51ce5c4572779fa5edc69fb6a18fc94feb7fb6" }, "kernelspec": { - "display_name": "Python 3.8.2 64-bit", + "display_name": "Python 3 (ipykernel)", + "language": "python", "name": "python3" }, "language_info": { @@ -316,7 +320,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/docs/src/examples/Using-rio-tiler-XarrayReader.ipynb b/docs/src/examples/Using-rio-tiler-XarrayReader.ipynb new file mode 100644 index 00000000..0eb3085c --- /dev/null +++ b/docs/src/examples/Using-rio-tiler-XarrayReader.ipynb @@ -0,0 +1,972 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "45bf509c", + "metadata": {}, + "outputs": [], + "source": [ + "import xarray\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from rio_tiler.io.xarray import XarrayReader" + ] + }, + { + "cell_type": "markdown", + "id": "d2c1f9bd", + "metadata": {}, + "source": [ + "### daymet" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "feb70fe3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset>\n",
+       "Dimensions:                  (time: 1, y: 3728, x: 17268)\n",
+       "Coordinates:\n",
+       "    lambert_conformal_conic  int64 0\n",
+       "  * time                     (time) datetime64[ns] 1980-07-01T12:00:00\n",
+       "  * x                        (x) float64 -180.0 -180.0 -179.9 ... 180.0 180.0\n",
+       "  * y                        (y) float64 83.78 83.76 83.74 ... 6.126 6.105 6.084\n",
+       "Data variables:\n",
+       "    tmax                     (time, y, x) float32 ...\n",
+       "Attributes:\n",
+       "    Conventions:       CF-1.6\n",
+       "    Version_data:      Daymet Data Version 4.0\n",
+       "    Version_software:  Daymet Software Version 4.0\n",
+       "    citation:          Please see http://daymet.ornl.gov/ for current Daymet ...\n",
+       "    references:        Please see http://daymet.ornl.gov/ for current informa...\n",
+       "    source:            Daymet Software Version 4.0\n",
+       "    start_year:        1980
" + ], + "text/plain": [ + "\n", + "Dimensions: (time: 1, y: 3728, x: 17268)\n", + "Coordinates:\n", + " lambert_conformal_conic int64 ...\n", + " * time (time) datetime64[ns] 1980-07-01T12:00:00\n", + " * x (x) float64 -180.0 -180.0 -179.9 ... 180.0 180.0\n", + " * y (y) float64 83.78 83.76 83.74 ... 6.126 6.105 6.084\n", + "Data variables:\n", + " tmax (time, y, x) float32 ...\n", + "Attributes:\n", + " Conventions: CF-1.6\n", + " Version_data: Daymet Data Version 4.0\n", + " Version_software: Daymet Software Version 4.0\n", + " citation: Please see http://daymet.ornl.gov/ for current Daymet ...\n", + " references: Please see http://daymet.ornl.gov/ for current informa...\n", + " source: Daymet Software Version 4.0\n", + " start_year: 1980" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ds = xarray.open_dataset(\n", + " \"https://pangeo.blob.core.windows.net/pangeo-public/daymet-rio-tiler/na-wgs84.zarr/\",\n", + " engine=\"zarr\",\n", + " decode_coords=\"all\",\n", + " consolidated=True,\n", + ")\n", + "ds" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cafd96de", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.DataArray 'tmax' (time: 1, y: 3728, x: 17268)>\n",
+       "[64375104 values with dtype=float32]\n",
+       "Coordinates:\n",
+       "    lambert_conformal_conic  int64 0\n",
+       "  * time                     (time) datetime64[ns] 1980-07-01T12:00:00\n",
+       "  * x                        (x) float64 -180.0 -180.0 -179.9 ... 180.0 180.0\n",
+       "  * y                        (y) float64 83.78 83.76 83.74 ... 6.126 6.105 6.084\n",
+       "Attributes:\n",
+       "    cell_methods:  area: mean time: maximum within days time: mean over days\n",
+       "    coordinates:   lon lat\n",
+       "    long_name:     annual average of daily maximum temperature\n",
+       "    units:         degrees C
" + ], + "text/plain": [ + "\n", + "[64375104 values with dtype=float32]\n", + "Coordinates:\n", + " lambert_conformal_conic int64 0\n", + " * time (time) datetime64[ns] 1980-07-01T12:00:00\n", + " * x (x) float64 -180.0 -180.0 -179.9 ... 180.0 180.0\n", + " * y (y) float64 83.78 83.76 83.74 ... 6.126 6.105 6.084\n", + "Attributes:\n", + " cell_methods: area: mean time: maximum within days time: mean over days\n", + " coordinates: lon lat\n", + " long_name: annual average of daily maximum temperature\n", + " units: degrees C" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "da = ds[\"tmax\"]\n", + "da" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d29e0c33", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bounds=BoundingBox(left=-179.99998449579846, bottom=6.073484821356791, right=179.98170598363066, top=83.79467217916716) minzoom=1 maxzoom=6 band_metadata=[('b1', {'long_name': '24-hour day based on local time', 'standard_name': 'time'})] band_descriptions=[('b1', '1980-07-01T12:00:00.000000000')] dtype='float32' nodata_type='Nodata' colorinterp=None scale=None offset=None colormap=None attrs={'cell_methods': 'area: mean time: maximum within days time: mean over days', 'coordinates': 'lon lat', 'long_name': 'annual average of daily maximum temperature', 'units': 'degrees C'} height=3728 count=1 name='tmax' width=17268\n" + ] + } + ], + "source": [ + "da = ds[\"tmax\"]\n", + "with XarrayReader(da) as dst:\n", + " print(dst.info())" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "93d11d9c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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3LlzceaZZ2LHjh1Rm3379mH16tU48MADsf/+++Pss8/Grl27ojbPPPMMzjjjDMyYMQNz587FFVdcgWazOejBH6A05iQJZqgE01SCurKXY12Hg+6OEEJIBRiUcG3duhWrV6/GAw88gM2bN6PRaGD58uXYu3evb3PZZZfhBz/4Ab797W9j69atePbZZ3HWWWf5/Wma4owzzkBvby/uv/9+3Hbbbbj11ltx3XXXDXrwNaUxTVlvZwpBQ6ydlQotLkIImawoERmybfLCCy9g7ty52Lp1K0477TTs2bMHBx98MG6//Xb88R//MQDg17/+NY4++mhs27YNp5xyCu644w68733vw7PPPot58+YBAG6++WZcddVVeOGFF9DR0dHvebu7uzFr1ix07TgEcw6o4zXpxauSoje7lB4BekXj2EP/baiXRgghZBRxz/E9e/Zg5syZgzp2WDGuPXv2AADmzJkDANi+fTsajQaWLVvm2xx11FE49NBDsW3bNgDAtm3bcPzxx3vRAoAVK1agu7sbjz32WOl5enp60N3dHb0A4DXTwIvmNbwqdiZXAjvpuFc0GtUO3xFCCGnDkJ/uxhhceumlOPXUU3HccccBALq6utDR0YHZs2dHbefNm4euri7fJhQtt9/tK2PDhg2YNWuWfx1yyCEAgAYEvZK/XFKGgYIRNdRLI4QQMoEZcjr86tWr8eijj+K+++4byfGUsm7dOqxdu9Z/7+7uxiGHHIJeEdSRCxTT3wkhZPIzJOFas2YNfvjDH+Lee+/F61//er99/vz56O3txe7duyOra9euXZg/f75v89Of/jTqz2UdujZFOjs70dnZ2bK9IfaVgtYVIYRMFQblKhQRrFmzBt/97ndx1113YdGiRdH+k046CfV6HVu2bPHbduzYgWeeeQZLliwBACxZsgSPPPIInn/+ed9m8+bNmDlzJo455phBDV5gRSsV+yKEEDL5GZTFtXr1atx+++34/ve/jwMOOMDHpGbNmoXp06dj1qxZuPDCC7F27VrMmTMHM2fOxCc+8QksWbIEp5xyCgBg+fLlOOaYY/CRj3wEGzduRFdXF6655hqsXr261KrqC1Z9J4SQqceg0uGVKrdqbrnlFnz0ox8FYCcgX3755fjWt76Fnp4erFixAt/4xjciN+DTTz+NSy65BPfccw/2228/nH/++bjxxhtRqw1MR10a5QOPzsfiYxnXIoSQqjGcdPhhzeMaL4ZzwYQQQsafcZvHRQghhIw1FC5CCCGVgsJFCCGkUlC4CCGEVAoKFyGEkEpB4SKEEFIpKFyEEEIqBYWLEEJIpaBwEUIIqRQULkIIIZWCwkUIIaRSULgIIYRUCgoXIYSQSkHhIoQQUikoXIQQQioFhYsQQkiloHARQgipFBQuQgghlYLCRQghpFJQuAghhFQKChchhJBKQeEihBBSKShchBBCKgWFixBCSKWgcBFCCKkUFC5CCCGVgsJFCCGkUlC4CCGEVAoKFyGEkEpB4SKEEFIpKFyEEEIqBYWLEEJIpaBwEUIIqRQULkIIIZWCwkUIIaRSULgIIYRUCgoXIYSQSkHhIoQQUikoXIQQQioFhYsQQkiloHARQgipFBQuQgghlYLCRQghpFJQuAghhFQKChchhJBKQeEihBBSKShchBBCKgWFixBCSKWgcBFCCKkUFC5CCCGVgsJFCCGkUlC4CCGEVAoKFyGEkEpB4SKEEFIpKFyEEEIqBYWLEEJIpaBwEUIIqRQULkIIIZWCwkUIIaRSULgIIYRUCgoXIYSQSjEo4dqwYQNOPvlkHHDAAZg7dy7OPPNM7NixI2pz+umnQykVvT7+8Y9HbZ555hmcccYZmDFjBubOnYsrrrgCzWZz+FdDCCFk0lMbTOOtW7di9erVOPnkk9FsNnH11Vdj+fLl+NWvfoX99tvPt7vooovwmc98xn+fMWOG/5ymKc444wzMnz8f999/P5577jn86Z/+Ker1Or7whS+MwCURQgiZzCgRkaEe/MILL2Du3LnYunUrTjvtNADW4nrzm9+Mr3zlK6XH3HHHHXjf+96HZ599FvPmzQMA3HzzzbjqqqvwwgsvoKOjo9/zdnd3Y9asWdizZw9mzpw51OETQggZJ4bzHB9WjGvPnj0AgDlz5kTbv/nNb+Kggw7Ccccdh3Xr1uHVV1/1+7Zt24bjjz/eixYArFixAt3d3XjsscdKz9PT04Pu7u7oRQghZGoyKFdhiDEGl156KU499VQcd9xxfvuHP/xhHHbYYVi4cCEefvhhXHXVVdixYwe+853vAAC6uroi0QLgv3d1dZWea8OGDVi/fv1Qh0oIIWQSMWThWr16NR599FHcd9990faLL77Yfz7++OOxYMECLF26FE8++SSOOOKIIZ1r3bp1WLt2rf/e3d2NQw45ZGgDJ4QQUmmG5Cpcs2YNfvjDH+Luu+/G61//+j7bLl68GACwc+dOAMD8+fOxa9euqI37Pn/+/NI+Ojs7MXPmzOhFCCFkajIo4RIRrFmzBt/97ndx1113YdGiRf0e89BDDwEAFixYAABYsmQJHnnkETz//PO+zebNmzFz5kwcc8wxgxkOIYSQKcigXIWrV6/G7bffju9///s44IADfExq1qxZmD59Op588kncfvvteO9734sDDzwQDz/8MC677DKcdtppOOGEEwAAy5cvxzHHHIOPfOQj2LhxI7q6unDNNddg9erV6OzsHPkrJIQQMqkYVDq8Uqp0+y233IKPfvSj+O1vf4s/+ZM/waOPPoq9e/fikEMOwQc/+EFcc801kXvv6aefxiWXXIJ77rkH++23H84//3zceOONqNUGpqNMhyeEkGoznOf4sOZxjRcULkIIqTbDeY4POatwPHFay/lchBBSTdzzeyi2UyWF6+WXXwYApsQTQkjFefnllzFr1qxBHVNJV6ExBjt27MAxxxyD3/72t3QXluDmuvH+lMP70ze8P/3De9Q3/d0fEcHLL7+MhQsXQuvBzcyqpMWltcbrXvc6AOC8rn7g/ekb3p++4f3pH96jvunr/gzW0nJwPS5CCCGVgsJFCCGkUlRWuDo7O3H99ddz0nIbeH/6hvenb3h/+of3qG9G8/5UMjmDEELI1KWyFhchhJCpCYWLEEJIpaBwEUIIqRQULkIIIZWiksL19a9/HW94wxswbdo0LF68GD/96U/He0jjwg033AClVPQ66qij/P59+/Zh9erVOPDAA7H//vvj7LPPblnEc7Jx77334v3vfz8WLlwIpRS+973vRftFBNdddx0WLFiA6dOnY9myZXjiiSeiNi+99BLOO+88zJw5E7Nnz8aFF16IV155ZQyvYvTo7/589KMfbfk3tXLlyqjNZL0/GzZswMknn4wDDjgAc+fOxZlnnokdO3ZEbQbyf+qZZ57BGWecgRkzZmDu3Lm44oor0Gw2x/JSRo2B3KPTTz+95d/Qxz/+8ajNcO9R5YTr7//+77F27Vpcf/31+MUvfoETTzwRK1asiBamnEoce+yxeO655/zrvvvu8/suu+wy/OAHP8C3v/1tbN26Fc8++yzOOuuscRzt6LN3716ceOKJ+PrXv166f+PGjfjqV7+Km2++GQ8++CD2228/rFixAvv27fNtzjvvPDz22GPYvHkzfvjDH+Lee+/FxRdfPFaXMKr0d38AYOXKldG/qW9961vR/sl6f7Zu3YrVq1fjgQcewObNm9FoNLB8+XLs3bvXt+nv/1SapjjjjDPQ29uL+++/H7fddhtuvfVWXHfddeNxSSPOQO4RAFx00UXRv6GNGzf6fSNyj6RivP3tb5fVq1f772maysKFC2XDhg3jOKrx4frrr5cTTzyxdN/u3bulXq/Lt7/9bb/t8ccfFwCybdu2MRrh+AJAvvvd7/rvxhiZP3++fPGLX/Tbdu/eLZ2dnfKtb31LRER+9atfCQD52c9+5tvccccdopSSf/u3fxuzsY8FxfsjInL++efLBz7wgbbHTKX78/zzzwsA2bp1q4gM7P/U//k//0e01tLV1eXb/NVf/ZXMnDlTenp6xvYCxoDiPRIRede73iV//ud/3vaYkbhHlbK4ent7sX37dixbtsxv01pj2bJl2LZt2ziObPx44oknsHDhQhx++OE477zz8MwzzwAAtm/fjkajEd2ro446CoceeuiUvVdPPfUUurq6onsya9YsLF682N+Tbdu2Yfbs2Xjb297m2yxbtgxaazz44INjPubx4J577sHcuXNx5JFH4pJLLsGLL77o902l+7Nnzx4AwJw5cwAM7P/Utm3bcPzxx2PevHm+zYoVK9Dd3Y3HHntsDEc/NhTvkeOb3/wmDjroIBx33HFYt24dXn31Vb9vJO5RpYrs/vu//zvSNI0uGADmzZuHX//61+M0qvFj8eLFuPXWW3HkkUfiueeew/r16/HOd74Tjz76KLq6utDR0YHZs2dHx8ybNw9dXV3jM+Bxxl132b8ft6+rqwtz586N9tdqNcyZM2dK3LeVK1firLPOwqJFi/Dkk0/i6quvxqpVq7Bt2zYkSTJl7o8xBpdeeilOPfVUHHfccQAwoP9TXV1dpf++3L7JRNk9AoAPf/jDOOyww7Bw4UI8/PDDuOqqq7Bjxw585zvfATAy96hSwkViVq1a5T+fcMIJWLx4MQ477DD8wz/8A6ZPnz6OIyNV5ZxzzvGfjz/+eJxwwgk44ogjcM8992Dp0qXjOLKxZfXq1Xj00UejmDGJaXePwnjn8ccfjwULFmDp0qV48sknccQRR4zIuSvlKjzooIOQJElLFs+uXbswf/78cRrVxGH27Nl405vehJ07d2L+/Pno7e3F7t27ozZT+V656+7r38/8+fNbEn2azSZeeumlKXnfDj/8cBx00EHYuXMngKlxf9asWYMf/vCHuPvuu/H617/ebx/I/6n58+eX/vty+yYL7e5RGYsXLwaA6N/QcO9RpYSro6MDJ510ErZs2eK3GWOwZcsWLFmyZBxHNjF45ZVX8OSTT2LBggU46aSTUK/Xo3u1Y8cOPPPMM1P2Xi1atAjz58+P7kl3dzcefPBBf0+WLFmC3bt3Y/v27b7NXXfdBWOM/w84lfjd736HF198EQsWLAAwue+PiGDNmjX47ne/i7vuuguLFi2K9g/k/9SSJUvwyCOPROK+efNmzJw5E8ccc8zYXMgo0t89KuOhhx4CgOjf0LDv0RCTScaNv/u7v5POzk659dZb5Ve/+pVcfPHFMnv27ChDZapw+eWXyz333CNPPfWU/NM//ZMsW7ZMDjroIHn++edFROTjH/+4HHrooXLXXXfJz3/+c1myZIksWbJknEc9urz88svyy1/+Un75y18KALnpppvkl7/8pTz99NMiInLjjTfK7Nmz5fvf/748/PDD8oEPfEAWLVokr732mu9j5cqV8pa3vEUefPBBue++++SNb3yjnHvuueN1SSNKX/fn5Zdflk9+8pOybds2eeqpp+QnP/mJvPWtb5U3vvGNsm/fPt/HZL0/l1xyicyaNUvuueceee655/zr1Vdf9W36+z/VbDbluOOOk+XLl8tDDz0kd955pxx88MGybt268bikEae/e7Rz5075zGc+Iz//+c/lqaeeku9///ty+OGHy2mnneb7GIl7VDnhEhH52te+Joceeqh0dHTI29/+dnnggQfGe0jjwoc+9CFZsGCBdHR0yOte9zr50Ic+JDt37vT7X3vtNfmzP/sz+b3f+z2ZMWOGfPCDH5TnnntuHEc8+tx9990CoOV1/vnni4hNib/22mtl3rx50tnZKUuXLpUdO3ZEfbz44oty7rnnyv777y8zZ86UCy64QF5++eVxuJqRp6/78+qrr8ry5cvl4IMPlnq9LocddphcdNFFLT8KJ+v9KbsvAOSWW27xbQbyf+o3v/mNrFq1SqZPny4HHXSQXH755dJoNMb4akaH/u7RM888I6eddprMmTNHOjs75fd///fliiuukD179kT9DPcecVkTQgghlaJSMS5CCCGEwkUIIaRSULgIIYRUCgoXIYSQSkHhIoQQUikoXIQQQioFhYsQQkiloHARQgipFBQuQgghlYLCRQghpFJQuAghhFQKChchhJBK8f8DrW8dU/uBssAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "with XarrayReader(da) as dst:\n", + " img = dst.tile(1, 1, 2)\n", + "\n", + "plt.imshow(img.data_as_image());" + ] + }, + { + "cell_type": "markdown", + "id": "1c538213", + "metadata": {}, + "source": [ + "### noaa-coastwatch-geopolar-sst" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8f90e288", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bounds=BoundingBox(left=-180.00000610436345, bottom=-89.99999847369712, right=180.00000610436345, top=89.99999847369712) minzoom=0 maxzoom=2 band_metadata=[('b1', {'axis': 'T', 'comment': 'Nominal time of Level 4 analysis', 'long_name': 'reference time of sst field', 'standard_name': 'time'})] band_descriptions=[('b1', '2002-09-01T12:00:00.000000000')] dtype='float32' nodata_type='Nodata' colorinterp=None scale=None offset=None colormap=None attrs={'comment': 'Analysed SST for each ocean grid point', 'long_name': 'analysed sea surface temperature', 'reference': 'Fieguth,P.W. et al. \"Mapping Mediterranean altimeter data with a multiresolution optimal interpolation algorithm\", J. Atmos. Ocean Tech, 15 (2): 535-546, 1998. Fieguth, P. Multiply-Rooted Multiscale Models for Large-Scale Estimation, IEEE Image Processing, 10(11), 1676-1686, 2001. Khellah, F., P.W. Fieguth, M.J. Murray and M.R. Allen, \"Statistical Processing of Large Image Sequences\", IEEE Transactions on Geoscience and Remote Sensing, 12 (1), 80-93, 2005. Maturi, E., A. Harris, J. Mittaz, J. Sapper, G. Wick, X. Zhu, P. Dash, P. Koner, \"A New High-Resolution Sea Surface Temperature Blended Analysis\", Bulleting of the American Meteorological Society, 98 (5), 1015-1026, 2017.', 'source': 'STAR-ACSPO_GAC, STAR-ACSPO_H-8, STAR-Geo_SST, UKMO-OSTIA', 'standard_name': 'sea_surface_foundation_temperature', 'units': 'kelvin', 'valid_max': 4000, 'valid_min': -200} height=3600 count=1 name='analysed_sst' width=7200\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAa4AAAGiCAYAAAC/NyLhAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/NK7nSAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9ebxtWVUfin/HXGufc28VVBESA5InRSeIqKCo2AARLaAqP1uIRuUZG5QEA4lVNFK0VXQF1dxCwS4YxSRiYp6J6aQASYMNImITJUCklbwIxvCTkubes/ea4/0x5xhzzLHmWnuf5jbn1h6fzzl777Xmmt1aa3znaCcxM2NLW9rSlra0pWNC4Xx3YEtb2tKWtrSl/dAWuLa0pS1taUvHirbAtaUtbWlLWzpWtAWuLW1pS1va0rGiLXBtaUtb2tKWjhVtgWtLW9rSlrZ0rGgLXFva0pa2tKVjRVvg2tKWtrSlLR0r2gLXlra0pS1t6VjRFri2tKUtbWlLx4rOG3D92I/9GO5zn/vgxIkTeMQjHoHf/u3fPl9d2dKWtrSlLR0jOi/A9S//5b/Etddeixe96EX43d/9XTz0oQ/F4x//ePzZn/3Z+ejOlra0pS1t6RgRnY8ku494xCPwZV/2ZXjNa14DAIgx4nM+53Pw9Kc/Hc95znPOdXe2tKUtbWlLx4j6c93g3t4e3vnOd+K6667TYyEEXHnllXjb297WvObMmTM4c+aM/o4x4uMf/zj+6l/9qyCis97nLW1pS1va0tESM+Mv//Ivca973Qsh7E/5d86B68///M8xDAPucY97VMfvcY974D3veU/zmhtvvBE33HDDuejelra0pS1t6RzSRz7yEfxf/9f/ta9rzjlwHYSuu+46XHvttfr7E5/4BO5973vjIx/5CC677LID1/uEL3khKEZgucIvvesVo/NPfPCz0xcigBmIDDCD9XtMx4kAcisGOSeaWJEMc1nqQjkGAF1If0TgrgNC/h0CECMwRNAwAMsVwIxf+u+vPPC4D0vf8pUvxb952/PPSt1P+OIXpC9E5c/MFXdU5o0ZCCEdC4S46MA9gQOBdb4BbgjlxAAYCEMEAPz7//jMszKei52++WtuBEK5H7TiNPcdwF0AIkOmnwFA7g0BHKh+B6Rcb+5fJmIGOH/askTgjhD7AA5AXBBWJwO4A2JH4A7gAIByB8hemz5//VVPa47tMT/watCQf4RUDxOBmNPzE03hkMenHTb9y6zhv/5Eu52qzae8WtsoHbXzkH8zQNGeqOereuarG2COUR5TSH2MnRmnYWdyv/Rd4lJH1Y7OEcA9dL6ZzD0wFPdO4wOvejHuete7zs5Ji845cP21v/bX0HUdPvaxj1XHP/axj+Ge97xn85rd3V3s7u6Ojl922WWHAq6+2wUCgzA06+nDbv1ABE4gwgwOGbwCJXWlLSfgNgzAYJ5ueVEpgEIAui7VBwBdn86HAF7k20KUbn5HADGIV0DfA5EPNe7DUneXu5y19vtuFxQ5MbWQAUvmhSi5EzWBK2BYdECXgCudzx+BaobVEWhgUGQQMRBxXufzOFPf7SaAQmKqxIO5TyExsEzchcwA8z3KQJAuzmWIMnA1GssLSJL7Skj3e0GIO3nB0gG8mxgxZWZZ8XBhuoapTt37cOIkwooL8+3Kc0XMIPNqM6F+xgSczfFNnrFu58T4oKx95TNKHzBaGOu8TVlQHOgkYE/zFQIU6CeBK4zrUAplvNyVcwJao77pWn7/5p5zDlw7Ozt4+MMfjre85S345m/+ZgDJZvWWt7wFT3va+hXJUdLt730lrnrQDwMh4OoHPAsYBrzhg6dKgRgTuPiJJUpzLqt/WyZLWRRjur8inTniXEYlqvqk+6wZNbr93+ijpOFEj8c89hWIPeG/vuGHj6TOqx747PLymRW8nde02CMj5ZKCFps5ochl1RoTk/EreAU1IlDYbgK+H3r8l7yozGeASlzpeXeaBzmXFyAswNUJY5d7Dj2vCw0qTJNDXnBEVilIpKq4IAwLKgyyn5A4UCSG3/upa7GOWBh57uM7Xleu+bLvPgUExm//02dsMGOb0Zd996lJvFFJa4aaYO+Okz22QXl7oT/eqoNYnoMs5JI7d0Ss67y4w1977bV47Wtfi5/7uZ/Du9/9bjz1qU/Fpz71KXzv937v+egOuAt4w/turkELwBv+31fjDX/yqrpwCEUSICoqPfuXyzRXEl6lKKBlVJFgBg1R/7zKUVa454viIiD2aWyP/v/ddDSV2vm0akIP/NFdk0FLGWk+L6AFZmDgJGENXKucdNV9fhcCx42q+YqiDqMsLYW8gs8gJNKVML4ugZaWEfWSHOuyJBYIsc+gtGP+FoRhN/2tdoMeiwsg9vmvgwKfB605hu3pd3722gRMImlYOgvPzTt+7tpmnQJaJFLWOmqAjJ0LNp8HHsO6y3w/ndR4WDovNq6/83f+Dv73//7feOELX4iPfvSjeNjDHobbb7995LBxTiiEoqOfLZfvvIBL16XjyjxD0jmze7pWq7I8ELWXUAYj5sxQuy6rQgyzDlQ9IyzAeB5p2A1F3XZUwgoR0HdjoGpFa0QktS0V5ghyK7oMWtq/GFN5RlIPIkticPaELa2nANCQn11VI2UQIvMeiE2JDCgpmKGSkgtw5d+BEPt0PHblMwykEnUBvaKO0vpEDWWVFvKs7PN+i92mOkYb49/RUgsALPi0QAvmmGUdGwzAqzxH497PJDgb42HovDlnPO1pTzvnqkGhqx58HdB3uP0PX4rb331jde7q+14LDLGWtARsiIqaThisASIOlPTPXvWnevxaCrNOHkwhMdoQct1ZjYiQlNpWItkEaI+YrnrIczHc9QT2/sou0BkDNYDHPPYV+M9vPnj83VUPeW4tZcnLFWORmiaIGMmOqGooQN8OIjAMeDGDQGUxGJHUhBH4ur/58tRuBN7yX5974LFcLHT133g6cPIEuO9UixB3+vT8hwCOMasJQ15MGRCLAKIADNTuGLMTjUhalpKDRQKr8hu1CjADFbGxgVENWmNbk/2epTkQHvr02wAG/uA116ydi5G0hSSNnQtKc1qe4dFCcUJCa6pKaa22cZrIzyUm5xkEddTwdRwVHQuvwjl6bP/toEB4094vbHyNB6sREeHqK/IDbUHLPyShPq4rTfnU+kKpy9rCrGciDwC6+slU8MqXBNI6rnrIc4GPfwI4cwa3f/ynNx77Qen2d71cvz/6629KbwAXqWe/dNXnZaBzak9hLrKiVyIqRn+7LhAwigzKTAmAAb8xeFUUJ77f2anvszOFu7nZPkUsElKovQANkwSMJBWSilnUgNyh4qIiUcVFupBDBq2+ZpKj67wKTL83HsrMfP/bj6wHq/NF7/g5Z0ez1ECduXfPqgT1en87s9ZhitQ+bK5t2rpQnx+1fcR07IHrzat/AQB43M536LE5EHv8w16AN/7+S5rnrn7As8qKv+VqKvp6ZJDK6if9LbYokbis5CXeh75OT6IyW7nrrUSCGkjONVHMKgNZ2QXC137tjeg+vcSbf+uFa6+/6sHXFRd3Vd2ImlXGyOVeDOJiLaBk7s3AyTM03xs1DgPqlp2M+VZ1hZoJcONeX6R01d2/H+h70O4u3vCRH9HjV9/vGcBqAF9+lxRysbsAjHerSiuUgWfRJVDqA4ad4lVotRAiRYkH4bAbivs1ie0mzX3sE5gNi3QPk3oQqjqUeyfPnh5rkPWCq4+Pj21C7/zpcyNdeXrHz13bAC/3rNrxjKQx81mBfbloIzW5kfY2AcqmTfCI6dgD136JF930uZBWkgAKeBnAsC7a3KEAi0hFMTtTiLqv69KDYaWsGaIuJHf3ENKdGYqEJW3zebZvAQCtIihQitGJnI3G+1BCyFj6rB5dsYYZUA8wkuu7viO9uLmHcTtG6hWGCNQvZ/JGS28vxdxe1sIWZ46ztzq8oKjrkgbAATQveqDvsh1qkcvSiFFyF8CL5IARFyF59e1QcdEeWNWG3OdYqgxWwy4VxwkgXyMgl5hd7Mu90EWi6YP3UksHS52j+L0pddYxInHK8M4ZGlPVAGj1hpQ1oCzcUJdnEy8w5bySHJlq9ax8WttXFet1lumiAa437f1CJXV5etyX35BW7otpxn/7/ygeclfft15labCkOFjYF7/LQIWQnqModiquXeVXqwrkEAiIAbf/2Y83+3P1FddUwLlWxXmO6L+86Tl4zGNzwLaA1n4cNWQF7w3lzCnQmqgCHmv74thwplD3anNfRqvM8tJTzOqRUJ+/M9Dt//uncPW9ngZ0KQSEFyV+ECEHv2fcKl6d5ncGFPEs5Y7UHkUMhGWRnjQAOF8zLFBifYA6DkrtWNkGKYxQ20ZezDBakkXFcFuS1gQ99B/eBgnoBTazeZ1LUu9XkU4b4NMi61RRg51Z0G0ggWq4QgO0xp2dqdOAY6l8vu05umiAC5hXEb7pt18EALjyq1+6UV3WNf7qBzwrfbGgRVTsM8owRYdWGC1ivlMivQmFkHnrtGHlDR++baO+ng/SWKlYXM039tZStVMdvE1DVK8vMmUrd/cAt+w2bteEOqgVaMdw5WwOGi9W3Rfga7/2RrXL/Jc3XYRJn/NiKjlV5Pnvc1jBIpRAYSCrxLmSgmJXXNWt1x8gUlO2V/VUMTPv/afqJCstbcBMlaYYpJcEbFkGvuiHboMG8F7oNCFtCVEDx/0czJIDFJLXmDB6b5oSbEvqmmjjKOmiAq5NiPsjULWpLYtr6cvZoZSss0YltfXH1hWbVgz0yKCVVaTeoWK2AnljyrxUzgDM43k0pDaXXjJq+JesSGbyKVIzLQcwAmiVj9l7YzwNOZyFN+5CoL5L3oI53lCkVe4D4k6HuNNl771cPkvVFBmxD0na2qEsUVFxpAhAyOpBULJTWXd0TZNEHsC8iATHIIsULaqt5FRgqMFoN3K/vsBfvzDkdyzWc5m+5OcbVIFHFQrAUHW+XCPLQrJ1AWPbtQEizqr1qcDuTRYcVZ1Gyj0I3fmA6wBZJ97wvptTZgegOG0Is4sAiCv7S+3AwQXoTAojH+x83CiNLX/qipDx+C9+IWgVgTN7lepVM2PYlFYAKkeMlho2lyFmcJZebeonn5GhXLOm/8zgmItpzFHqh/qIdISvedwrLiqp6+r7P7ME0UveQAni7pLdkfsEShUDQ2KgnG1aMZfRWKr8F2GkLMyo6dapvFpMzYKZsc2oVD3HPB2IWenlD159zcRF559oYIQBuniw6cyKEMmqeaiAQV4tr1kXPiX+8Rbo7MJCJa9S5kAOL3buj2gteKcBrisf9TLEbFQ+CAkTVjfulqQkjJeTjYuDu08Xkcfar/7a8/T7Y7/ixdV8pDRKIc1VzgKiElFDPZfKl+OjJKOZlEEJaIUJ0AJqxmccMMqx7D6fU0KJ9Cya3uTBNi/1HTe66oHPBiRQ3toNBbyyulAkKTAbdR9pDkkJDFbAMqCxkTcZj1f7a2mG8VkQ0+LspAdz/L+96sIFKktf/bdvSbdJFociEeXzagu0nzC/7fPO7jNLrWAquMKunNSR76mV5g5E+5TQ5uhOA1w2TdGRkni0LSTrA5Ug4iECC9bs7kXFePTdOJ9Ey8aAcmZwoKjodKXfl4S5jABCBCN7e8pLAmrPkwEt7kIBLX25WpyN1fW6yqidwUvscwyYWLBc9uLBreLtqv7N+Z502dlip0vviDhKqPSUnS0WobipW/WdVSFZSau1Ooc/RjWTrbgyKsbcyrk3/uEbOABIXigkmozBgJfNEGK8aIEapKuYtjier9lUT05C0oS5U2CziYSd661U0FvgWk/i/XRYuv09ZfsTiUfiPr38zAAhxapo8HCg5Cm3KluS7DflzIVOb3rn9fr98Q97wbiAMshgbFKo1YTBMFMhl/xWPBE1EFsB0ZyXJv0cG4+sysvKdpNRBzJHgHAR3SvxEOuKmlDzC3bF1iV2EQCgZV5pR2DYzUAWqGJudSZxaas+TiO7lSxopvoqBevrvDPBRjnwBLywhrFeoJSCrqnKSF9JtvadyesSOS/zrtOT572p4vPgRHKfjmAMh5SwPN0pgOtrrnqlZnL/r7/y7COr9/Z334irvvD5Re0C82J0HShkdWHekoOs5HWR0ht//yVpToDs7o/y3XpbNuxY1ad1zvBlTRJXoans7/Z3lQNSyP0W8BrFMDn6ur/58uOXGkrybQ45cL6jnPS2zq5fSTt53oiBYSdxQetiPVIf5SZGHmi+L3Mr+DVUuXhbac03wQ7g8vcvvOa2Zrt/eOqa/XfmLNBXfOetCEsurwII6Mx2LiIRk5t/A9AK8FKpSFlWQm7MXRUHacruO87RS8722BHQnQK4PJM70rotoxVGJ67BlIwAzAxahRygvHdRAxeAenzCLG0Wd5kzydYOGOCfAivoccksbqWtdE7ab0hcLQZnUlYRqAZLYN7GdRzvoexp1uWA7pzJPUrmi8gaXM6UVIMqcXLycOMAUIeSKqgBQBVjzNeOyghjlNutxxvSk5XuvMSFcmun3MVtPXPpjy4U0gTELntPFGlJ55fWAhABlV2/2jCSshpStMd+7Wfu2ToVoN4DX1a+h/G9OwxPvpMA19mrm5jBK5P6SV/oHF/U5+wZIeTtNdKOy0dJVz34OsS7ntBYtfNO3vUfGO9cLE4RCh75nEhjDtC0ahto7GK2UgHz3QfOklH9qav8hmPxdAydNrgPZa4lh2AGr+LyjmT7I4B7TumcCNkxA1CjPqDOGV7CGjGk1qLBXrPJar6lMjzA2qFy2pB6Mn3hNbfp9z+87fxJXylNFqdsLxa7OMfMiYpVFmiMSoUoWfiBrDm0qtyAEk8H0TAYwPFz7KUtrt8ZrduDl1yPhlSu49nXtFR0UQPXo7/+pgOrIzYmCZZlwxDdyl1z5vXJFZxixNX3ehqYGbf/6Y9NVv34L3pBYe5Sf5ZcqnyLmrnj/NPVD3hWTtFU3pZxADDG4/IqxAnJayrQeB2ptyBwqLmSLPLHSU141ec9B3zJbkoXFlCkX6NyLdnHMyhlW5eqi4JxfzeOGRaAmtJSg9bdt6qefUhGc+17J4ZyYvP6zwU9/PtPJV4hi4nsni4UzSaZlI12FJF2agb0fnkJuPreACgFmhbIsCnOBsim5s2i11ma24sauCrjb76vj/qmm/Fr//ZZR9gIFwnBxXaNmG4fUuJSZtBqAA1D2j4iBPAlJ3D7e1+pZdXJwQY7I9cbqXaCOIqg6iMijXnLwOWZFHmAr06Owap6ieR+ZpVjK5edvjAibWlQFo1fopk8kuuyZh8r6oLm6LSAJfMIZIbmOJa4X9uAYrsliTXcK3idoyFtSrNxZJtIhueAvuTvnSrtGinWSlICSLEzz3w+n8okAJN8j7FHkY4MiBS7WL7vZLPUTICWnausVqQGQKZ+5S8SDwZzvZOyD0MXNXBZz6aky22k/zksiaHbqr8o5mS4sn18ccdGQJJIMoBN952SVx3TSJpTpqqOD7igXOxpiOCOTTb3fMKCPJA3hPQXO9Cy+nmxb6mdS1Q+juGCNau8tiskHnGtzBw2E8F+B30BE+/0OSNGiWNUABN1UETaH87cH5uiqVJRWQYEo3Zb25HcVn4P5TovJVXZH1o2TKlr4tqm1OUlQjKSg2XM5wG8FPRF7dpYcFeOEnm/M3UvFw9QA1yagotRhYBU0rKAmfx0UhRN8RQriVsgMnOnWTvyJVHaFGAGphcVG9BFCVyP/vqbsv4+H2Dg1/7dwaWsr/rWW/Cb/+qZzXPWPd7S4x5+fQ5wNaDVkgIMA1X3eslgL44DokYbsuSVpS6lnKbo8V/yIrzxd2848DiPjKZc/q1ENQXaLTUrkBmmkZwyqHEcy0W6XaRpx26hoc4Yc/2PhWk+9iterC7kbzGB18eFOKdpWp3oCnMJRXoi0RBw3mtLduVZUCVhAQZU3Op8CryqYPLGlLeAp1JzzYxr86TO83UIeJ1Lu9YXP/VUAVKRgvL2L5MxcPaYnaOQbGK2niqubtLBSN4FVOBd2bMmwKtlx7TB0dWNY/N8WFXjIehYA9ffesJtCCdOIm1HYlYVgTRI8qhyAX71E28BRcav/5vNADClFIolGDlLSpTBh/3+XNbmY5l3R2CE5Eo/pXYD0o60yCrGCPCiq+KrzimZYGuvcqvuyZSGU8av2TSgIK5bZsiLItnEZdWpjIByzkGqt5IHFPCEyiq8LCTSzsp81rxRzyXJ3llxN+9Zlp8/K23JtiSxp5RiCCX/4GFicEag5RjapEOHVmCKz/XhIJLSNJ6eVfrip57Shq37OectX3zy4UoidJKNSsAdcn5NaAB5qrct1UKrobwYRg120hbcMXu8sYCxdbRsiZLElyb6tCkda+CS1TjpxKc8alXg42pG5N2QKKsA911PTP+qzOlebZXP0RDL8xDyrrJ5CxRCUj3q86ESmKnPbPdxlIB9ILJ2OePGrp6AMX0n2c+sFbslJKAlq1FRo4idRiQpya0nAEZZ8goJONO9My7cJucbhpgBzy0kAD3O5thxI8kwUhxbvEs1a8qtYUHgQb7Drezr33p8ivN7hjtxbiRlOUnOfp91AFkDXpPXnevbahZXyvztAsGCVy7fVIna3z6o2M49jedY61CJjM39MLwEvqH2WKQdC2rRei76OhiHmvdjDlz5C0F3Wo198ZKSNCmHdcZIkhIABh71jTdvpHaMJ/osXblUTw2HhDf8yav051UP+uH6vKRFMhKXZvOm0bNoOhDx+C96Ad7439q7PZ9NkvFIrkLZuLByeQdml9BqQB5JWkXaEv28SEgM1g0uVbKgpDaswMvYzUgy/BtVYmWLM2D2q7/+/COZn3NNsjO0xKWN44CMTY9ynA/MIsGca6kJgTbYqP2oRRNSXKteD15HRVU/yQQmW+DIZf7olmsO1MYXXnubSrRpJwXbAehcspeS3HzqPJjxV96FXlrKfSfUdVXu69U4qbpevUpz2bVi6czzYPn0CMAOSMcauHQFDui+QPLSTelnv/Lbb80Zl9Os/fq/ngahr/q2WxBWBbQAAJQ8EzlgVm04nOgRlhFhDwBicdwQ0hin+u6JZ+Hjv/iFpVx+yuoEswHEQ9lYUSQZ5HayNHbVFzwPGOIFswklAAWFjR1lZOxSvNpKJn2kd4sUvNgCEqGWvEyWCM7/fHzXxUJf9+iXJWk0MsKKMeSVORs17IjJyJxaCYsxBi0jIayj0c69c9/3ydwqBt4sgGocrWtnrwHwBc+8bd/gZTN0yAKBrMeClYT8vMqnVw3OUQPs/Fh8G1MOMlX7+5FUW331bZs+HpSONXDFBYGyHj5JXChJHGWV48BL96bZYNJoQA1aVT3z164u7UBDh/6TQMcM4hWw2sedGhhEnPioqAWt2i1AB6sBz8KQYyxZKYaYfp8vsnFBlsRr0npGVueorEY70tU/m7+RNBAzeBlpLNmqoO65drEDqYJTGySq14jUN9Pn47pvWgo6RpqMvHiKfahz2UlZZaCkzFQWghUjXANWLTBYq+IzfbHFRnYSd82+bJAt0GoBhrQ71famZKW3rO5jkWat5BvK3mWT4GXqVByyEqPtN0p7av8y49Uh5X6BGk7JBvSCzZcg/IhFikT1Xs7G0dn2vfpwn0TMx++NvOOOO3D55Zfjy7/xJeh2T+TgSNQP3gB0S0ZYsgZQCqil1DbznoaP/JabtZ6m3lh+o67nkU+4uWQiiIydO1boPrMCnRkQ9tITcPsf1rswX/05/wjou2qPrqu+4Hkpq4F4GEaAhkEltQJS+QIJ3hUV18CgGPHG33vx2vmco8ff9XuAYcAbP/3PDlfPl7yoSIxC9m1xtrDYl6SvcREKg5GgWc3YYKTMhv5d77e6xqNiBprmSD6HWNz0jRdktQszUj9+9QL1MLz6Ac9CvMsJrP7KSUSzm/HqZIdhl7A6Se15o8JQ1e7ScqBxDKeZZLdV3rZjFiLeNjJl49oEuOyGi3retO/Vgxs7nsjzwvXv//YjbSnsC6+9rRqLuocbVaF1xqjG0RiT9t0Dk+tjVUYXfuPv1TXGOcdeJ6TAlcvqcxFKndyZjUMbEpd/pigC8TOn8b5XPhef+MQncNlllzXncYqOtcRlAakFKPrQRDlIJXBvDV5Xumg92D72yG+5uTDRDKJq1M/bb1DP4CVVaqq6HiqbLRKlWC+/DFKbjwEoqc8Y3gFK0tpRZZYKAVfd7cnAogctFgCAN/yv1+y7mlZQbyu2rqzOSNPXKGi5l6pc046+YqCKBSuB3GVFqimhZK+q0KjHqSYvVNACUhD44x5+fVnV6wKA6vgtAGCqt3oxUoJXFep5rJGgLM0wZCUHToRDrMa9hDJDlRTQAIG17QD4oh+6bWR/qnYLtgDrJT5dfGGzuTHtTp2qtKK2rJe2zOM8koakb/Z7rpi4fi40swcBsjPRFCWQ43rcB6RjDVyxp7TFkF9BmAcobQlgwWszUk/F4J4U9yIKeHpddmKwnBKVZsYQVrENXLJPFVH6BDBKgZSDkSupxUoEythLX+gofLlDyFu0ZNBkrt34NyVxdDBgLGmf0gtHBbRFqjJMtsp1ONWEAReNT2KoswYYOZUOFw/EQPrC0UwmDal3NgbtQqKcgzBtWQIMuwGrXdm5uBRL6yuqJJFRDkK0waRi2PrMmXITC4wWVZKQr0cL2YpQGKlnyMJoG9Syz6mTk5OmWv2r6jF1sZU0gpGi/JxtIMGOzonKz1/i5qNSJhh+NEoB5QDVYmqVdV/KCw9lqOlC6lBTipH2/DOg7eXg6EqFekA61sBVobYXnTOzi2YyaWCVjOJinP7pUd+Y1YPqXQbQygQQU/ZaFHFbV+yNm5CZY+zTSRaHhECjLTH4xA6w6JP7u3gMdqQ55QCUwGNTf+qsAJsBrUr6Ohy98RM/AwC46u7fXw4SNdWbnq564LOTZ9/uImUkdxt51sGpZdzcBfAipPQ2ZofjEbOwDhuwzNZwPwBsOJuoRThCPRClPopJRUlDrG2YNh5NwOsCp7TbNxS0lpeQbqQ6YsLBCT3mnaoXZK4RbpdNjbj7O0FTThtyrrX2mpX0hMHPAalj3PJJcOW0Y+NxjqQlw7g1lqoxpsn+uwWAlXQrD8fc1gho7Ke7b/XeXabfQqFqbtxvqSsW03rVN5lrp26UMsIqR+rZhmZjUzrWNq6Hf9tL0S1OjEELZeJDNrgTp0SUlUQSjceguVF2q2yKDETGf3nTc5p9+eon3qIPcnlBEkhJ8suwTDaW7kysbmj/qSVobwVaDsmInrdPr2wqWUJIRlJJAZ2XPuIhl21C+oAFSq63OeCZzixBA49sawelq//G0zMQpaX7Gz5wa7vc/Z+ZwPrkLli20gg1woubv4w79mkO4k7esZqozXhQA1cFWrKKtqtguZSTfZMkO0a2f1EEwjLbuBgZvMrFovK1C4O3vPXCUhde/YBngU/sIJ5cIO72WF3SY8h2rb27hNreYsgvukaehQBGnM0u0y2RZVBULQJHiw0vBWDNcVvEJJ+dtA9Nqdk843Zja0l/VX32fSd3vQW2Vp9a/YJ7VjdZF/k6XN+rU17t58e/Sfu6+G/0QebDOPxEpzbknD8x7rC+m/zp0/jAi59357NxjchPvDxAXK+sy400yxe7Qsir8o03nawkvXpVyyFJd4GAgdLdTI4AnKSpRQcrYcU+VFJGykOYY5IkwaYNPPZBu6JuE2/DDrmNmFzjmXH7u16+2bgm6A3/76tx9RXXTJ6/6oHPzk4O3qXTvRH53rCRtpBVhBKX12ZypMcsY/VBmOyuA5DmMs8Nifq1y6UDwJxjwigAq1ikbcDY6NKcf93ffDloiBdEfNfV9/4h8F0vSQsAoFrMWbugVe2tta0Ig/GgJdQAr7E6sQ6Mb7Xh61zrgdi6bq6sfLdA17pe2IFfINU/y3xaCSIX0PmamLNKsrPH5RIHmlXDo8KmiLTLrpy/fmLeVGU4Na+E4rTklT+je1765M+ncJSJNvZBFw9wTTzoJReb7GUDtVsRFXtN0c+Wlfi+STyy5KVHaY87UkkurIAQOWc0IM1aICt6C1qSuog5HSNJpxQNI5XMG31ZVUvQKTMUHMEMOr3c/7haFBLQtuw9FWiJ3c4m3DXJdXVvKN0fqnwvjKEAFeDAyjDDShWBxjs6sRqlmMArxgDSScv3RJ+J+mIJeL5gaGdR7KMRKjkKVapPGs/ViNYBg0zRFKgxUDnL7AMgZ1WBc0A5BUb5s2V7q5iqUcVZkBsNcaadKbvcyA4HN04qzY6Ac6LOivL8jdYWVlr099sNbJ1NUSWq6LBTFpB2ziakSo7Tj8x+6HgDl1MF+YdfVtzWOFiv4ssMM9a8MFNkbpb8JcDKTxGb+on05glzTu9JqGwB6hhihCnKDFQkAC2fY46Ixz51qS8lUS93Hair3SVFCgOwL0lMbFtXP+BZuPr+z8Qb3n+LaTgzenHosN6QgItHYz2nmd+r1Wy5X15t5W1cFegjAUv1TIzmJ4cs53lKk2zA0II/jSs5yiwOhyVZBAFIz7lxgwfcClnKbLAS35gmmKmcG8fxmfNz1TLGDFWqP2yfJxs93OWeca/rf7PsPvswWrRFc9zXN1F3S0KS3wSMFisVkE+ArJc+hf9tev+n6HgDF+YfbE82dqSO5yAFi/3kI/yqb73FgSDKw8PAb/38M7TsV3znrdAtCJjwa//2h1tVNunKR7403eTISZIxWebfZLLBP/arXpJAwD0RyeUcoC6Bl+zlpbFNhyVmXH3vHypIa7wdIRKV9ZKs8jUGA1SSk1DuUwagajVXg9W6ez8rUbCRaPNiIl2Uy/RZohwwlsBbeRXPJ1XeqEFVrmWVXNR1Fc+YY5QttZOUnVql22L7eDcPRa025to14sImwDipumwxafN9X7zJPtZzc+sZvm3T2bKatjk/lvzd2rjU5d2rJKkIA76vrf4W7Y8FVCpS5Z0WuMxET65+/TkGQs4ZGLJjBot3i9w8Khu2zTZvJAcxTvocZ7YsExCG5FK+H+o+eUYZvoCN2MQs0ZkB1HMO2DX9z0wrLjpQzuGHgXPmCpcEeL8UY8rOMQx1f7oA7jug64q3JFD1K0pwtXF/9+qMEjxpzpkVHHd5XoRJEI1fTgeWRX2c79cg6ljS+aJct9gJQw5M1kzzwIUHXgCQM2MMJzoMOyWIWz2+Qp2rsbq0IRQBEwzY3adJ/Y+5d3puH49aJTFsIMXM1lEdRAVe664Z2cUmJAypj/38tEgXT3VdhIlr3SJjnTRV2SZtmcznrPs6fOC2mFXcpfrOyLXWHR71WDSezaThg7Eg0HDwd+d4AxdgXgZBsTIZZETSGry8ROLqXDOfX/Wtt9Sg1XhIf/ufPaP6Xd3Qfd6vN/5+SpR71UOeC6yGaqdkS2965/V43JddD1ohqYqAyiU+5TSkwsBAScWY+cpVD3kuEHlfeQ3f8IFbk2u8z3ofQgKtRT+OO5Pv9gVsBZH71Z4DLVtX0940E3M18vriUj/lrP2WMbAzdlgJ7MpHvey8ByTf/u4bcdUXPj9n3i9j0efNrqbl9z6ew2a8lKUJ5q8qwimwckx4pA05CBkJY1/XtABhn7yhun6OZsrMgpYvMwFa1XsyB1p5QfOum67RIl/wrNvWLi784nBycROyYMCzO+Dtm443cE1JWg3xtwlWjDo+R4pmpvror7+pXu1rFoL5Pk2eP+Sd28QG9aZ3XA8AeNwjXpwkBkYCEq/qsuq8kNRh4hAiWd1pNQCrAXz6DG7/2I9PNzoMKTBZ6wsJOLswCri2djfbj7mEu01vNE9T17dsK/5Fzm0QcQFBgoI+E7JRmcG9kV7MM3UhgFclAdq9zLBGCtjPc2kXEOb98/aakYQCjL0LG4xOhzLFkPdJo3o2lfbm5skDnBzeVDV40D75Nhtz3GxrgkfKvbKgJec2qtO1b1WD+kkF5Cpb2SHpWAOX5qBrPEiUV8XVC+VWCApKsoKWY1mVVGXQ9hKBUGYWVC5v3hi9cUdw0zaiGEEoW7UrI3MAxh2lcsSa31AlFYkjWxPIzKucW8pmAFn0yctRfpugaIYsBozbvxwD1CEiefMZO43MsWWOTl03bdMygKTtMDwTUWDtbL/SqlHTVXWEDhG0ZN2E74JQGTKnTSNzWICEFFROLTPP8kGezU2f6QMnKV7H2KcAxoO0Y6reIYLM97k6tTy7c5v0z6pKDwCiIylrYiHS1CAZSWvt/WrNwZwUSyhmkmDmh+x3g+pU3qmD0iGSbpx/8jeA7I3RnIQo++FU5ycqFaYoL7d50e3K3z4A1aeI377ac8zX3vSO643Np/xpvj/ZiDGkRL68MAHCDqiICFd99j/AVff4wZSz0JPZ8dhcBCvVaUYMccDQlTtVUmzL0yqdmDhui0zMcUuFkY633nrTL3vvxWVfs3mY8QEjif5c01Wf95wUB3iyR9zpcl9hQkDkz6i2DYOv7CGOmpLaPu8Du8XJXPnq90Gm1fZ3CogafbDvfOvPzkNLVbdWGjrIuGy7tk5/bKofDRqNoXG+anNUwJSz6a1MUHat1i8Tp4vAwIdCn2MtcdlJqgMleZLxCbVUhKP6xYi/D5oDxQQeB8z1dwB682+9EFc+6mXuoaYsIWa1jZyIAEJIGeg9dV2WxBjcdXX6J70+JHXh3BYq+tKZNE7qkNG4FwpWrJLx1P2ae1knPcLMIiXFppREvZ4xSVxc+sH63AXzHF351S/VeX3zb75gukNng7rkiMGLoMl0Y0cKVjKWSWYEI3mY1XVLammpxEaqyBnwmjq/lqYY/xTwtdqQ+4kJqWkObCbqXCd1Ti7G1lFj7uX7puC0SSD3Q374tvF8OnCrngtXz37Vz9UC4YB0rIErBRWX3W8BQFItAVBvmfS9JFoF0kskwcZAZkyNO7Ov7crnHt4qoHaDsR0VOUlAN95EnVhVVKuIpPYu7kIauqgOY84bMUQgZoAToOMI7C3Tu7CoH6vKSK8u77L6t6ha4YkemwxAlvqq1d30+FsqFAErC14gjOwxGgQO1NtMRAYFmU8e2xLPEYnULNvBSE5NrypsJdAdkV+RN8pNBpjawPIpmjol93/u/Wjc52mJuu6bv86eqySFdW032t3knT6IqWAkac1Ri0/ZU5Re05G9yc6pnQcLYL56KRPdb/fMVIscYqfF2GBME3S8gcuSuIs7Ha5lQKMV5dRK3H6iZpz1CjSDgOe2BDziSbeWa3L8kt60c8jbJB3RY7/qJSVjiLURtYgoOVYMMQN6jmciBmI3BhcAt//vnxpVI/FiLar2g0JZSDAoZXMX9aG7H15t1VIrWcmoFbPS7AvlC9kAmCcFzpK1nkI9DiBJuueKHvfw65OULA4xuZ8+O/ls7jxDI+0FMOZaG6ziq8M2FKEBaHNODYeOBct9aoHGxvXOANZZI5r4vp/rGePxU30Mdl4agD9JXMBw8hr7LGTQsqrNw7DBY23jmqV1syJY5DRb4y0A6tW+/77xCk1vcGKSX/Vtt+Axj33Fmk4eMVlQb27bQbB2qZKxPcVkcZ8T6xKBugDqAhC69NeiqVV346kjLxlRXmzoCt6uAsn1tTXWNmg1Dde6yiSz0GhUqbbCfF63XTkXnKxNb3rn9Xjj778k9asLEPvlCPTtShpjacQu+Ga1CXPvVVOaaV+wCXP0NuS111qgPku3ZKN6j6jMpgAyWbcFCpT3pyUZbdKvlp2zaf9s9Kslwd15VYXMOeOFRRLU3+Xh11lGWdn76khSANm32p735U0Zq7oIdRlf3+TNPpsUeeSa7u2Co1g4HwuWM+Wj5xR4TATq0ver7/U0oOvwho/8yGw3Jg3veQ6T758BJM90BTjsdW5MdYNoS12Ne5C6UKsOR/0E8r5CCeECAFrGoioMlDKYGDon9q4cqlGcMgrITkmplfqsNaczc2nr8CCYqpvTm7vvVNczqYbEjOTYek+P4h1b9676uVpXdurZnWL0U1VV764puyFoGmGslrpMHaN5nFr0zbSjnW0dPwQda+AKA0Ad1K1SPAiLftWiCUrQaGaQVtqqV/z1darWMsckS3KlJmyBUgvsst1h2DmHAq+JSaKYAT9GVXUhZ4SQpLeQZJh95+w8GVasEwZz8izsxuMpdp9YdhhmmOV0bt/YvOzO1sUbLZWtGNcUb9ScjmiEQcwsTDABXoTRQiclBAYiEnhh4JQaytq5jmA/tI0p37e4CBgWhNgj2bj86rqxah4x3Yl5tUDn1UtaXYuZjiqa+L6G5F6KB1uL1jmfaD22ry1JxF2zvnP7u6ZllxuBB437PDuva86Tu9eJF5TFQNMRzc+Rr8PeB3dP0nzWKsI53rgfOtbAZakKKJ6K1m+tvJFv3oyh0DO7/RhwR8fkBkow8zkiu+q/8pFpXy5iJIYLYOTKHjhlYQBKJvdIKVmv2L2EjKR21QOfnTJm7PbV+RSOEPM+Yxmvsl0yGXmpZM9wDL8paW2gILcvalNl5V6kihnLtBjwaq2KibKknl/SqowkGt6QrvrC5yOe6DWIfBN63JffANpbAbuLrC5EZYOrGLL9XoG5+XnQR/KQDGljgMiMedJmZcFn4hmprm20e1BtyL4By4Mk6nP2XlU2Kk+HYCPe9uXBfLKtCaC39Y7sWkdIFwVwjVbXwqzMasL/HqmBGqLyHI1Wb6EhbcFLDOX423++Tgl1Lmlu/6jHPfx6BRRNigsUcOpCBpsCeJRd6dXuFKh4G4FBiDk+jBPgkTB0Vqaf7kuJMZt6aWSViFL92vs1cg5o3CetH5mRZ7UxeAK8BHjlQEBKC8UABYAkZdSGXoaPe/j1CMygVWvZuzmJqlByzWlfMTFmnjjuycxN65zWX0ke63dAPgjt22FDbpNffDSY8JGB1qb1rAFZLWPBy7a3STueN9prZSEwU1dzbA0wqhcEZw+0gGMOXApCOXu3pvIXCaFDBWJg1Dt4ZiJmYKCR6KuebpbZ6Wf7bqx7sObUHBcCaeaMqQBbouSgIRkrmMGStd5cR5J8lzmVl4DDvI2JzAFJdvgugPPTaF22/Qp6I3diI8mld7O+CbP2A4LhcSauKz9LNt7LSs3MlJLxLmO2BQ77c33uqGwCuV8y9ylm+1bMyYmnQGWyKjZlGxqKkSeaJ5WI/JyPG5+zaTWrNlV48Forjbv3seWocGi788RiaHJxTO63+T66X1N99OP24ARU4Rvs7yvqtialWNv/KZAnRpRH2DhV2XqJYbe7OzAda+DSbBhxwl5lQSsfX+sBNgKaBmg1aM5QOunUcSFSZd/jwhSN+lC7r0DFpayoxmw2jbx/WFI9xhSAnSeBEUF5Q0oaGNRRAQinvl270hbQcatTX2Zy6I1rqmwpYvcaikTG1kYIlGz1OVHvOvq6R78sPb+B8Kbff8na8k0yalSN2/JMvTXulir9IM9mvq6OC6wrn1XhT/VpCpg8bdrn1ns4805vSlMgM39Ro9x+AbQBWlOeoRVwrHkHqvoJ40QMZs7KQmfaljWXlOGgdKyBSxwu7KT6bUUmPcMOQxITNvGQTJFdyX7p955CGBi//U/Pn8rQ01Vf+PzUPQIQSlZ6AHj8F5fYJN3TyTqtdKFWixnQ0t9EuvGlHCeEBF4IyVtRVoa+c1qe9vViN3nxzMp97jqVwPI9ZAFtQgIqk7dQzhEnOxSQQUU2eJTnMwPfYWK/JJWXagxmFldTLuZmkNXCq3q35ubdrsYPAIAbv0sT5ytpYk4qgWnnCEBr3JGJ4/uVKvfBU6p72lqMbKSlyACVy28MbvaYnVMvpdnn4wjoWANXWHExwRjb1awrc4P2FeWvgbNy7bhceSkMB2ysZPabTuqsk4CDySjiz1lHjYqM+m/kWp9d8TXYONrvMd2zBUBDQBgieIWsjkRZSaINMCPSKTdJdWPjftlLzItqjdX66JDpAyjZ6mR+ctYMPZ+zyEu9Ogc5dIM41iouhMO9zMxAn4KPNcWTWSC1r5k4blfR5reenrhulHnikFLbpArQ36OpfrjyVTPmfV2rdjsgVeoxJwlVfXDS5cagNQeOHijWka0r5xmwDjDr2hVpq1o0STl57+z7t2G31tGxBi7veJEOlnMjYODCVFpbiYv7tVdzWKbmj00yz1buPapfugtSZSju3F7VM9RqVqv60TnLv2//o5dpuaseNLPTc1YhIntcUBfBA4E6lPi8zI2aUpE55l2JE+PN6cACr115evDK1dT3WUIEyDx7nLZDYQAI+XtHSZ3YkW5KWY0ZCchAlFSqjh7/sBdU0u46ijsd4k7IG4iiCTj7lWQqqbc1b2vApdkE+2dovj9NtVXjXbRS1ghEJ+qeOmYXLpvQJkC9L1vnZEPjdlqLrXYHGrfQ9dtLSc1AfTTmtJERo6qDgT9+3rVa/HNfdmp0Lw9Cxxq4gA2kK7/yPIAaQ+uRKhxoNV+mBlXlLkTQEm9Cn+kdyAx3/fW3/+FLR9dVPz3YyfmBNb6MxFMxv42bPOAjnb4cBwxQrK+nWSeV6xUQYTi73Nd8TfKSTIVTHkOUVaezIaYhEh73iBenqpYDMKcBMPS4L78BkvsxSvDxzLUbM9DWOzKh6vHODZt6Ek4xxnbhxu+DSBSGJtVdGxxr1j8nBcnnJrfV8wYvRc2Bk18rr+sbnARqfYM21AZVvMy+dPbPX7OmT5vQsQYu60Wo4DW1mnLg5SP7x55n65ZRUg6j1UNT2vJ9EeZ2ARETJUeJhhRQBRiLSjGs94KjyOpkwVadmA6UcpyCm4vdsnhzpnMTczrBxEYqP7Mit01PMnMXt6fgB6lT+sPAUGxf3FEy42W3fxZw6qDjA4CwiskTU2x+ByFRWw8RxEHnWd+LDhityqdImKL5lOGNPNL2uWBbRxurgFtMcq7Ofazqq8Uou9/rqFFmFBPo52rT+vfRfwoYrTcVkKbUlXbhYZ5D0pvb6pNorcr1Xv1ZhSj58YTc1iFE0WMNXEBD2mK0bUetlUzzgTPqjAqMAB8Eu8mLNPUCMSXG8qXfdwq/8zPXNq895xSgQcZA2Qk5xXQ1AI2KehCYAHu5XkArg50Ey5KT8GiIwIoQQgRTVn0NBLKZSqr63W/zAlmVnyZE9guWJpNsS3nC/OWeJjUkJZCVhiUAuAewU9SESQLLSaBXDJxeISwBGoZ0rT7H+3iZ87xK7FfsSZ81ts+cBS2jwhnXt+Z3rku/Nu7HWs/dKQbum26db7xDrfqr7Pf+nZ/pk1+Mjtqc4BmTKms5bxNrm3rnrqvOzbRnV2J+befvP8sYTMWlTK2RSOr17EEs6kCYuZlbtFjQcgW4F9BqDGwfdLyBq/GQUF7VtqQeb6PZmDZZETp6x+sKGD38yafWPvAXFEkAsXeNt+QCk61dqyKXkaOSukLIUgjXqglRGQ6JMeuL77vgGWfru5GwRC032T80znuSFbTUCVQSv0rROVhFYlZiT6BIAANdTn/FkYFVAS37TD7+YS/I6sV8zMTJcR9SzJeoMGUvro5GjKUe6wxoraE5O9aBAoLzZ4t5t6SByb406pyVtNxzsa6flaS6Bmxs3yq7kwe8Bqg2uyOSTS5YzbNv3NiCrYt6qZ+k6Xk7lRmH5A0dmUam8kVOzOkDX3qqbDhpAuPvvDauxmpPRVW4ZK2+PBUgG23hruft74a0NdUtb9fxGrXWw3yhUEBJvzQkUKHIGTxYmSeAwvRnmP0bPpC2d3n8F70gZ89AYcCdeVuMEwMxAytGCIwYylY1LOq6VnszjKriH+yZ0fhCb6iuxuraYyC7onP14tpnjynlDqSsDaBICD0lRxR5NuxCoWFf1E8BK/kdgXgiIPapjTlJw3q5jabJg0ZjrKPva0j3wJM+WFCpVvtAUz3XWNVPqhVN2cpu44Bns467fmIepOwY/PmW08RonC1J1wKgOJTpMzWPuha0LNBNLTD8/SCG8TDkUTDx7DxOdK0AV6nvzitxZdJNAInSKt1QlQmeDNrLPko5E0JRL3g0XL+qX2vfcrFlWu9mwzt3pE4IVH7ntEaSoHhkwN3UThdQgxahtvEFylt+cd6wMoJWyUsPvV1xNubXr3JRfstpBS97z2ZUYeMVswEMQ/rcsN0hOVdlsq7omIlBC8IwpImknHE/eSaWyU2Ln5g29awaNFwtBPCCsLxsgeVdOqx2k/q1YtgTkup4IPmzFQfmGGmLJqWuBjNvAuGa96rZtgUoy1w3uH4kRW3ClFu0pvxo0Wquq1R5DfNGBSicF+Mz819JWH6eYZ55/+y7srMAZcG8qty1JVoSAdAAcM9FVYjpZ2kTOtbAVaVnauSTE8lnMiM4QzMhVLTuYWw8FOk46Uv08Ccn8diuVggYPTC/+48vEPtWi0J6a8SOcxh64++/JOVB7LPkqtvJjwGBGMnLsBN3edQrcmy2+qtVK+U9qmxf/gU2dW9UTiRxI+03VXI2X2JI0mbsCLToQENMoB1i6XdMQdmMCESTeiwAvNMjLjog5GzwJwKGBepnX/rMmF6gr7mlXmLwEtIUQEwlNJ5aYIzK+bbmiMb3a/K7ee/rezhui03ZA/sQ7AcMpxZS5jy730C5ZnJObdsWbew99ODtUcldx61iLVC0x0TiEvvWlBi7IR1r4NKVljyEFryEmqI65cvZ3TyqyxI2XhVY0LJBoP4F0p8XnLgFvPH3XjzetVgkpGB+N85vQhQjIrklqCwuBBhtDFkskt5Gi4lWm1wzYJa62N0PW0eLKZgyxYZgbEoQiTGPKcd8CfCSWzhxR4g7IasPs4PHQDoXFCk9zAOl7WSQn7E+YNjtwIsUcBx3AlYnsto1t2+zx1jvrtYcNVWEzXfGzU2L2WH83e5v11zwWSZH43YmVX0tSc6X8b/tu2iPTQAwUC9Amir/KVoDWi1pcJI3uIXCVB+awDZ3TN4Jf182mUtH73t2WYA/4JWnRvWMHDI25KtTdLyBy5K+gARr65Jzow0i11VXvURUHW8CGpVr9EFoMMLZ1eaFQGSWTOJIoUtaAypAc/+t2apXEdg1B4xkLNnUdW4GBvWcMlFY5jtV95SqarIzdR+ajM/fNxB0GxbjeTqSRLJOxmYI8Quq2It9K+QkqKxqbopAGCKQk+5WQNkRht0UbMw9NFuGtiFdtsH5VvJaxwS5/tmas0k7WIPhFlUpylxR6Q+7+0Cm/pEk1Wpjrn8eBFV9ZeZqkzYmnomp9kffW0Ubz2vVB2uTWteW9GcfQLP2vJfO1hRt1StgfBiV4BRdHMAlD/26oDnDKKv9tYKRmKoXbAxY1ffWA966SY2H6oJVEea5qdQ9U554sqvyBh6aVz34upRZYojJNhMZVWqkGItUwpKUl8vvmO03PMV1c1FCLWV5mmIW7twmC4xqcZOvV2wIlAzR2ZNw1MdcJi7yc8sE6mVegRipzdwCIe5kD8KAsfODdMXqc4BJJjjJVDzIzZWDKdOas5bqyfbTtDXpyg4jlfj65hjjBJhpXr6pa6y0ze3+rGt7rEKd6WfrYlmpTQBIZbObrasuUNl+py605TE93Qzg/c/egJdx5hNHtGi/KIBrPzn/JOt4zZjrMpUaaFSBlDHMnRpMzJNp7nd/6sIErau+8PmoHDM0PyElRwyi4pYtxIzb3/XytXXf/u4b9fvjvvwGVAG9RuKy9eo5YW6GGc+9dC2bR+s8MLHOmLlOcuPry+/BxYEXsjraq+SKVE5m+x3KdaZK5VmtvF+RpDW7UaRKEQawPX9Yu+qlxpg2YDKtwORSH40WiVN1YJM2587PSBsjDYiemO1Wfb1v39VXAdym9XlqPCTqkLHhM22raKlYR7a/KR5nFxRVQxPXZLr/TadqYLP9YqREAkdAxxu4WFar5dAcINnrRmRXeeSYgq9r07lnNG/iBUvLFagL4K6DZIBXkt2aA9Vblhxk40Ob5knqkS1NAAVGcYOnXC79JmVyyqDnXj44fuCWi1Vw6By1mKZjFjCMiwnJIE3ZsWWijaatgws4amwWkOxewEgNKHa1ks1jzereLrLWMEQZl2VYVd12EbEh0DXr5nzbqVGNBwjUv5sqxQlShu4XmjPA165oXEdpf1qsXQtaretNe5P9c5hnv2+ieRgd98/lzCXNaq2kL+9YtuXqvB2CIR45cF1//fW44YYbqmMPetCD8J73vAcAcPr0aTzjGc/Av/gX/wJnzpzB4x//ePz4j/847nGPexy4zZEX08RKQWK7/M7IQGFq81JWXZdvo2KkVlUjD9CmAzpPdPt7XlH9vuoLzU7JNnaLaFR2P/Smd1yPx33Z9QW0ABPbYdqJETQQaAg5zgnNedUu+peXSlH53fSQNPd1SqWlh9as2smUsyrBtgrNX5yuls34VG1mbDNSt461Ue1c/zxjmuId1bNs58fWs6EE3OyWlQgYI8nMq+fsPbX309Y3HkTjkB+D/946vwnXnlspmODgqWu8anpcduL73DGpC2Y+bVn/rPv3xdbN06/H/W86NdmN5vsyBfD7oLMicT3kIQ/Br/7qr5ZG+tLMNddcg//4H/8j/tW/+le4/PLL8bSnPQ1PeMIT8Bu/8RuHb9itBDeSuAiovZ6oAii9ZM2Da1/01qrygpe2GnT7H74UVz3kuRjFdx0FSdzdXBBzzNLWkKWtfQp3lfdo/u1X7OtsCCNG56WNxprJqu0UvHz9DDiZdiQR2thCH8MjzEiva41piklP0NlwGqpsx3oMCkBeetuIpkC3MUZ2cz4FAFYSGy2ODvDYV+pojxw+3qrV1ynQnHhWtawt1+JfbgFAqI+PFgVcfr//WetNHA+4yXkUOqrU2IdgJ2cFuPq+xz3vec/R8U984hP4J//kn+D1r389vvZrvxYA8LM/+7N48IMfjN/6rd/CV3zFV+yvoRb8r2M0qHMRtvIS+jaq1d7UDYmstiD18rIqFvk7C8zhrFIr4e5+0mVNkKywWRL1BoyDx2Pam4tWUTcMpUjZtXY6a7yXFnwGlbINC2opzpQ5FOV7bsFrxADZ/DWkino8jfm2jMc8x/uxT0n5uXNT9p1qATDXlhv7aC7sPTJz1WLos44Em943N4b0PS9sMA4iH4HXBrxgI3Ug3NwSStoz195GAI3G2q/VDwdQ3Dhn2xzNxxqy2K8Oyu7+p3vMh3rX9ufPvCH98R//Me51r3vhfve7H570pCfhT/7kTwAA73znO7FcLnHllVdq2c/7vM/Dve99b7ztbW/bdztMVDwCM6nOH1j7QlWuunNFW+oWnviTPsT6Lwzl+3Gi29/zCtz+nleAliv9fvt7X3noet/4uzekLx2p/axlxE+u8jElps25+9T2tR/8dFU3HQY2WbzMlB2t7ufKOybdBLcW0Ln6W6v0kZQ4tTr3dc6MgQklRtGoMJsqdg9KHrRMOT1m66Xxta35G5Wx4/Jlp2hSbN3n8zVDLTtmc1zrGLkFAIxvYWvo1XXUKEONP9933kza0mvsJ1kJi0fPxkHpyCWuRzziEXjd616HBz3oQfjTP/1T3HDDDXjUox6FP/qjP8JHP/pR7Ozs4G53u1t1zT3ucQ989KMfnazzzJkzOHPmjP6+44470hc/4a271lysFkP2rArPnGvar3zx7A2mKhCGGiNthV/6vafwOz97YXoWTtEb3n/LuW1QtvugtAoOQ9pkkgcCvFu8V6fA/Xb3cbJcg0bqQnft6JR7PqbidUYB0ajLzWa72ASE/WVz4zBSSMsGVGsc7CKR6/GabrTDS2w9Zg5MdpkS6D7uR+v3nBTWAso5E8ImQFUD9PiCfUthWgDjhcnEHKzrZmuIk4BmPpmADzyj8KX73XpqfM06snNseGa1MDuCFcGRA9fVV1+t37/oi74Ij3jEI3DFFVfgF3/xF3Hy5MkD1XnjjTeOHD4AlDgWcR+Gd6luVOZVMzNAJOV1nq0HWouBmPKyGWLKoSgF8otOhC/9vlOaauodP3e8QOxskarEfFb6GEHLiEBUsqxTjV1yP2aNwRNUqYvZ9ENevjXgleqQMZTr4PtXX6I/Rgy29YyRO97oxzrmeGDnCalfvD7lNkHc3WHmgWpJyPfLg051LTRN2sjBhiaud8eq3612W8DD1FzQ+EOTC5Cp/uxzkVQqmrjGLXSaZXl8eLYP+ff7nznmQftRE97vVEqmXWIxUZ6DnDVDUz7NvlCb0VlRFVq6293uhgc+8IF43/veh3ve857Y29vDX/zFX1RlPvaxjzVtYkLXXXcdPvGJT+jfRz7yEQAJtAS8OO+DtFYEbZ1rvfR2dShqDEAZ0ii2CBmwdEsOqKu+qgtXAA1AGOQ8t+0XdxaSjPN5DkaSg2RMj2auBFh0gbDBO9CSum2bdqXv+7EBGGjasRkmrb8nnr9RuzN9n+tT05N6H4Cl5QngDoh9iRsbtU/I6nrST31fWtKOWzDKNhcxt8MdyrYXpp5JgGqRqbsJVnaM1ZdGX1sJh/MDZxPFVn1s1TN33xvnNuLpU3Vv0g/zN9l3oPC6DbpT7hmXHK2m7pHENanX3IzOOnB98pOfxPvf/3589md/Nh7+8IdjsVjgLW95i55/73vfiz/5kz/BV37lV07Wsbu7i8suu6z6A4RpGPAyDKR5Q/VmpRM0NXlTKxoYRjnDIKbKWNtXGEpg7Zf/3VvxZd9zALH8mNOb3nE93vSO66H5Dtc9jS79E4DRQqIOh5l+M1j2Bpt7cdfQiDk3jjfrbj47M51orcBnGOKB7H+tcbQY99S19t0z/Rx5SrYYmbed7feeuHftoPezqpJcH4EEWAd9XlrA0TpfdWKugxPnW1Kea3MWbF1d73/WtVUewhbd90durWyUCG6e9KbLn/l9QDpyVeEzn/lMfMM3fAOuuOIK/K//9b/wohe9CF3X4Tu+4ztw+eWX48lPfjKuvfZa3P3ud8dll12Gpz/96fjKr/zK/XsUAvqQa+CigBcDNEKN9DG5qiXAeqqNvGps2UYfRmUy86jUkZIFwrjfEw74IlxE9KbffhEe9+U36Pw3AUeS75r0T2o/JAIYZev6vKDbZKnYXHBP7L01+XsfdZeTE/VMvcym/DglV/6wzGyTPjbAdeNgVXds1uMvf47sXMGdW9PPtbSBWhjwUjGPGrc/p75P0gZzNVWf/txEg9BaXM+oJqfvD+MD1z5Df37ujWkBzQFro1/u+6pbAQI++I+eMVnmfqduTap98bbOatm06/fBGd+RA9f//J//E9/xHd+B//N//g8+67M+C4985CPxW7/1W/isz/osAMBtt92GEAKe+MQnVgHIB6L88EekfZskRQ4YZVIaq9UqezbGN7UJROv6YdLyaB60hsSVquNip8lAe2cHr+Z+VxpWkFWDAyMsGdxz2X+Noe7xksh2ThKijZlbAxzWrXD3y+Rcuf2ojX0aqCaZuVhrj5Hj9pgswKLZV2xqDBPz7ftRld9k1e8Z9AzY8tQ98v2xF+XvvO6haCB6M6i32fD4/Ny4R3Z6O9Z8yDpAtPtbPtcB9WSfN6E1VZXdy03g/xHo+Yj5+BlZ7rjjDlx++eX4km97KfrFCRQ7EiMMQGVgn2Qs5UdRU1DRz8sl1n3dvtAZdERdWT1ckZM9y17L6Vjt8kva9rCgY+dpeJT02K94ceINMaYs8gPrxpMcAtBRTjDbYXnZAnFB6ixgk81W9hNljoRJUDEvv9yvViaWliqyaRedeN6mpIFJx4wW2bGsacfa3ppOSK1jrr6qDtFmTPRLx+L7ZBkn1fdn35LWGuDSPncYMcdJ0GoV8BVOBAyP+mn5Q+u8b87NvzbjeE5lArGXzD0Hci1hfrxO4jobdL9Tt5b2xAZGwHD6ND7yw8/HJz7xCTX/bErHO1chDKIDAAisGw/6u4z2iwAUWyGZ3/ZhnFMPSjP2RWECU8mvJ22lFYexxWVvSKn/4d9/Cu/86TsveClRY9Kzl2ZYxeKtWTnMZKlL1IRcbt1oT6hNyfOvca9mxtD+PamCdu216qsA2DFJrXd0DSqmVx1vdFO7UklFjXZb5e15DzBTzBuN8+ukiHXUAlI9x6Nzmtmi0a+RycH2Y4q3rJNC1o2jPLjja9dcumkfbGfuf0v2cGbgfc85C/zHvjhGE7WfV9HTsQcuQFbbaTYiMvOyWcWF3Expgt7Ws9lSMfqX32+Hkq+zrvAkQbMDQAMjLgCxyaQOM0p+vv2P/aKhQEnKIrFfAZXThqjGBkbYi2AKaT8rgqqFRWUIYGONyOj5WKdqIrRBBwXcqt8TEtrkyn2y7vFzptc0+uidC9aq8xp9iV3OXD8lGU2t8FvnfFsTi8jJ86168nz5eV5HLdNA631vdasJ4G5+WjHNo2MT8zNSZdt7OHFds39Y8/znF2SU3eds0IRUz4dwiT/rXoVnk6rM8KOXlIzHIYpaT/7MOblh+3FDHRlWY+kPRQGwAloVQFopjGEA7sBTcezpzb/5Arzp7S80YBXAISR1IJk/AGEZ0Z2JCHt5QWAlW0vrVHu+uJEwWrth23vf+hvVhcZ5avyhtDkK6dgAcCZBZb/MyLQ5BXSAY64NcBy5sbfa2KQf++gzr+nzOpq6j8365gAYjfs9caz67uZOv4tK1ZSZei6bbc313fY5/93/5qP1br7frae8CJs9EPlQ6HOsJa7/9E+errrRL/+7t5YVr9xoAD6RKVA/QHnR5gqY7+ZhWLeiBGDiu4yNzdrcTBui1koxSjiyvWqOM3FHSccvgAXAuzfRKqZnnoBV15Xjm0pZM2WqBa/80NUy5cunVxiza48KbBqdkGcNJpeiKTsFjk2pa+b3ZJajNarK6nzL3uTfj6lFw4aP+cavg7S1rjzx+jr3A1SON6QfjbL23CZg7oCq+k5m4dCQupvjmxBJR1Jm/n3/W05pPTaTxkGoetaISxCyfB6QjjVwWSqb1tUrQp55aHS7CZv5XADFXy9EQMtA3owyV2mKR+2LCrHeSHBLTFTv/SWUPZIoMsJyyLZMBu2GAvj5XhKoWmiMEtBimpnbRQ8MUI0SMzfVyzM30YHQiNEZpp8eLRpd2wTmGbBYR3MqT+nbWntvqw8WyFrOTVI3GuPxfZjoM3uwsgtLmr6+XWnj+5pxToL/XLu+nQl1r7Zly1L97HqV4saLE9P2qJ4pnndYYgBdBq2OgQ5JTXiIdi4a4LJpkx7xpFv1OzUYQ/VwMlJckH3ZYL6PvJOorHz02DQTmGIkuv+QedkOcyMvGpI5yNkY9LB1xIgxbXUSsi1xQLoBIpkE8zZOLFzWSWe1inECbBzJ9iPlAAwzqFDJNFR+TwHTdCfrT/9M2s+1NAd4TZDeECPXzNl+r5V3rYSWYP+g1XomHKhOXkONIr69Oelv4v6OmmyBKaAPicxDa6zj9FXygHmw8inyjpYB3f+WssUJCy/tDidpCV00wGXp7T//jAq8gFqcr1e2qHSt8vJPgol9QVz9atOyKylfh2VkcjOpZtJ3RrryUS9LX3I8l5+PSqIGkpTMSFueiJdGlyXYs9XJSsJm7decJNL8vcmtbpRpqQYrgKI1DNVcUzE3qy0g83lA8iaNfdXXAh4HYLMLxf30ew5c1qnhNllQHKZ9d+7QmOLG0Y5nnNLtHoyq+0RAUtUeTtISuiiBC0jg9RXfmcBr9HILaDBG26qLCqIYSM3W6V7VZF/Q1rZVlPK8/cYvPUuPPfIJN+t1MedXtM4jd1qy6loTjFxLpgQE8YRihFVy0qAIxAUhIricg412DGOW+znJxP01uT/Nc/rcuMBlW26K7HPUuHSd08GRSOytcWunxu2J1MP5Gl3sNfqm4NWoa6qN2TINSaOaowlRpC6D9lhNW6P33s/xFCBvCKrNYsqj5vpWpK7WKa3fjHVyvuxizHXqoM5i933VrfnaiT3zjAR4ULpogQsAwiqvigMpSFSqOUbeyr2+rlIV2gfJ6o0bq8J0r1IMF9Q7rL77sgsvB0leWsDrzqwqrJK4AqBAatdKB8RuRYhATmSc7F0plVbKUcM9QEx5UYKy0gttruFfVsCsRkfqxSzlRXNcgNWpFqfsXd5Gsk4aqZ6/dcxWB1S3Vfo/04+Juti3bRd2pl495YGB6iFOOXJMSlkeKOQYud+2/lyZdwwY1enfad+277Nvkupj/vj4gpnb5+diX3EUM6eJ2/zKt9vq2EFVFyGvQ00FmnhX2iSkbYsOSBc1cP3mLz4TAPBV33ZLYoSEyt10oxtjwcsfk5+jJ7fUP2KYUpfJaF8Ckvc5wIuI3vLW5wEAvu4xLwcA8MDjOOQc0+W9LykysGLQgkF5vy7x7izq/DHApIZyHS3pS8637rVh4pXzhpyeAS/fdn1d3Z8KtIDxc7KOp3nG5MZj7WJNajG2/S6w/LSvu34dk93090wd0+qyDeuZoFGVDdCb69sItDalqWv2MdflXdnw2hmqeJqAFKHOuH+I+i9q4BL6zV98Jr7iO27V1aOif2ZuzdWbe1Er29dcOTlnVqVf8Z2p7bBKzJgJecsIqoBU9ue6M9Nb/vNz8ZgrX4GAmIG/Ps+Bat4twBMZtGJQn9J+DQGjfJCWq9jV+Fo+wUXakmvZvpRAUWn6S/dhu2wtrJu2WXbH/Ip6qkm7iLfzmiXIWdusvb7R1lpnjRkp5kjIrw+dtDU5txPXA+PxVFJW66S02T48bstLY5OA7WowEuXaMbTuZUvy9h0/BH3wH6YUUvf5sVtSneL6flBQbtCdAriAvCon0v1+7Is7yrDhj8mDZc7bFZs+xGyu0+9JTZXKUtp3KOcmjAvTD1FBbQlxkZZmREkdmNI95cS6TJnRltx58i6EIYL3ACBkSZaTytbkIKz2qPRenchYZMr4RLucqs/Xo8ksqvqBacbUkpYm6vNS19TvKeanz6qU66g+aYGHGoDL7vtU8CjX744s5OwCwTPbWUnRlB+FnDQBKHHz8n5yWw2/5je3jq/juQ2U0oWsvV4WHi3QsSrNqfbmbHitPvm5tOWC3HtKmjtGuVmHXV34Z8Tfp0OoCo91kt2DJGcEgC9+6qlZ5wp96Bsz03r5iKHZMSgC3R6X7BlRdMzFtsVdAS55ccMA/O5PbfMUWnrMla9IXpqRQct0g3gR6iSkAeV3AGIXEBeE4WRIEq2bey+pqPONYTqWQTbVfZXEM/NyzwFT661z5SfVhQ3GNwKuhrRkn13ujdORZHzhOgv8bCJcC962GQsSstpu9dWMuQnoE2DvF5RWolA1m7morPRdPx3wV+fQPj7SA06pGpuimjvXaFv7OlXPqJ2aCY3m3rVRno16MZb4FiUeFqkKOr7/Lae0rtYuyevoPj9+i7aTbP9IgBUY8fRp/M9/cP2dM8nuQej3fqLcgIc+/TZdZnt7aJMleXWRLe9VLb4sIe/4ShVT+P0f3wJWi/7zrz4Hj3nsK9KPkLdFcKCVApYLwJS8kABynr10HKoSSwcAVSMCFWMH3P2EA6gpcGjQJJDY1fkEUx+BVmOFPi/FkTlc1GVspNW6Lqp+75sc06UkLB+4vmnVmTnvARsYv5zUWIzOgdKm/fX3w+sIW2XdoqM1xo3U162+NJ4PW2c63gBHJlRaIt+f7uCyzYd+8JlJZQiAkJOgT/RxP3SnBK4WVbkCW6tJQt4DygEUoA+j7MNV22XKij72SdLSLcoPcePuLMRd2r4kMFISXkABSxxbEoMkDW2gCNCKEUK+Pq9GNTzY8jST5DgMrImT9dZOpGbayBHCHGtKDnP337YhANuqv1G+AiA5JsypxdDsDgueAdtxTADsCFzlfZgamtdCbSJlUON0BezlTFp0znvTqU3OSyVT/Vlzv3ShOwGA2sdqAcSlnMzZHBi2BjE65vrty8v9FpJ8r5s+iwcgGkiDjjXZwyF5350euP7g1dfo9y/9vqxC7A1AbXDTrHRW9obKwbJGVSLZtsXOBt5KW2spsoKXvMg2rIE7KnaplZG88p5onDcXZZYwBSTmYZie3bstubo33OcbwDNSgQkAHGTF7NoaqbJmVtOAlc7GksbokgZ4UeN7s09T9dj5nGmrAkg/zoYKtKqjcbwFWvo5U1eLeY5Ay/d5TqLCeOwjKbD6dGX8QgA42DPUkpgUNNv9/tDTy35c9zt1K+5366mxN/4hEuKKs8Z9fzTH1Xa5L4ew6d/pgcvS7/zMZiDyJX9vOoOy2rFkbygDfgJasSP8wWuumaxjS4X+y5ueU/1+zJWvyF6hwpHTZ3q5WNUelLdI0T3POtbgZbElqNNMJhuPJF4cCgQbrLiFGWo9M4xnI3WQZ27u+ybXrjsmADyl9p50+mgwXm4cm+zLVJ+n7EPkyhjQGvkSNIB57vcsOaGoOt7ob22b5LrM1H3cT3/2S37xQ8CHnvrMyeJ+U0lJ23Qk2iHFbhp7Ze6TtsB1GDIrRFmtUTbD2KwCyOdinzzmtirCgxP3VElDtZ0jaGAygJQOClkqG0glqRI/x1kSy5VZ0MkbVYrDArHb3t0wKC8dMeXrbcdbK3R72jwnU2CxjtmNtmJx11ZAOWXr8W25sU32x9NEWYIbnxsnufIt8C794TZYzoHwRP+0bzMLFHt4ju9a0LLPxghcLf9oLGRmFz+tPjpptpRl084hEeMQlNTdfCR92ALXAUi8/77k750q6oX8IDJBY7XA5kXM3oR/8KPXnJc+Xyz0X27/YQDAo7/hpkoKYiJQYLV9hYGBvF0MrfLLErK9KxYQk/AEkcBE3WMXHdbxw6rjLGiN1IZSj2dUhkaMkDCKW2uSGfOoshkw2djo3wKNKdCyXeB2/yclxol6vQ25cizw9eoLVtenQNkCLbhrWqfkenmv4W4hQe2r83aourmWetW2acNp1tU5a2eV86oez6A1t9hokCzCPvhDz1hfeB0RJ4cp8SzcZ18sbYHrKMmuooQYeOdPb+1YR01v/ffPrn4/6ptuhmTGIGZwLMBBnBnCICtPifPhpF3sBQQyuKFmUpPMryUZtRiVv87VZ22pVWxai+Fb0Gr0ZxN77EY0U9eUSm4/bTXnbA5k5q5z39eOcaL/kzYuA176O4NC2kRnw/b89zXjHS0yNh2jLqRqcPjw33/W5CXnnA4BWsAWuA5HVoVtXgYr4m/VgueGfu3fppfykd9yMxCpOGFIfsEhq5WymlE37wxQB44odzNLX1XAq1vZi7Q1KyEZ71SyaOgkhQrghOk0pJeRhEWl7hGYtFbsmzyLtg9zjLIFyFVfp+tf5zix1ssNE+c3kbImrl8HWh58rBTIrQvNOKekU3/ZKMB6ok9NG2KlfUAJ7tU+HEw9JzFd933VrUnFvhNzHBY0HuvD3/2c+UoyfehpxbZ2xWtvSu/pAWkLXIclAa8tQF0YRMgbcwpoZRd4yRQhDDNS2tNLdgdI72ACL3E9j+W+NpnFDHPXZ8IDiauP7DG3Q0By5Td1igrJ1uEkowokGzQHKFWZCYbry7dsSHMqLA9a69RdQA3M2hYwm70DLUCncloCo9tzapg80/TipBLn6/7XKmSunwNjVx1dPoEvzYWDa6tU6Oo7Qt5EQ+47cwKeg5qr+BDX4lBOjluqH8by5z3VtnTuyO9UnJwwMhhJJn6b6imTZDmR1FIqqcUGc6EicaU2HUOW88FIZu6vKmfKwtUptlHd+ia4OuD64L7Xc1P6P/ozY6vIn2uAzlR7k3X6/hyCmnPbap8UM0w2Dx7NuWf85djEn6m/mkv9zc1jTb7gjo3G1GqjGifX55297IqfuhlX/OObGw1vRk2APeBN/PBTnnUo4NpKXIcgu9qcBKgtcJ1T+o1feia++om3AHl3ZKFRIDElENOtU5hBkRBWjNgjpcDJeSb9uzlmduP3lwBMAYKXzOSYeEvKdhCsYIv0HGlqpg0kFW5IQ1MMbx2tA8GpZ3xq3tYB3Yxtx9bjwbvpKWm/m2SvZW7Gqj6mfNkalV1VfuqeTF2AKSCYuGaTuu18RKris46EDAgqHSa/6lZVeH6Ic0ohZZCNG7uVuM4TUb4/jflPGVCyFyIME8nqj7BiDXIWXYswM28LIQZiY1uatFI2BU1CUXu86l4GKHW8sKrDLH1pbKCtwDNqY0/z11dlpsiv8DEDgqi/j9rbz/M/YZfjMM6CoZlEGmNvqdUYuZ5upq1G2xCbZwucbH/c7yY4W/7QAkULvPa+tu6XXTgBtWR3NniO1c3JYKNTS55D2gLXIUgyZAAFvEZAxcAj/u9b8fZ/fsSrnzspPTZ8K8Kll+KNf/m62XIcSF9+v2sxhwxeIFC+YYyi/WGGxm8Vl3jDS4xdZNL24L43XddRXy/9kjJV3QY0vNpy9N0zPhhGP0dT/Zr47vtTgZaASoPhztrZfL+lvN/Z2oLqxBgqPm7702q/4Wrf9Oyckubmjk0tZhn15oq2LE18usVRq+9HRbotiYqgqX2KksKJgMC44nWvBIX08nzou67bvIFDgN4WuA5DsqoOxR6ylbjOLr05/qu1ZX7jl4r30lf/7Vuyy7J94w1IVIyIICpDGizzrTmYXscNZgYjMU1IIi0HC5VQBnN+bgU9xehaJPVbpm6HNMWgzTWt53jKGaOplpySDFt9rfrNo77LOW5dY+rmKYGAzXiMVDUq5hcJvn0jRWsOvg3I11v9nnpu4PtsDsyA26FJ7oGt1yzcEBgUGB/6u5t5Fla03QH5PFLekZc0Rgi16hCY9kra0lmnYZGS74aBxkxeA6gAQlIj0QoJvFYAqUt9YYIMpK1qsj0qSg5F1HWLY4U/XpGRTJiQ2s/qtbkURlZ15bfj8c1V4CL9iXXbI0bn9quz0t6sxCQNm3IHWrgZkKNoOjgBYOOFgznmJFgPGFUdVrqeWnTYfph2uCkKYdxoBkoJt1AnEZjnSE54KVDKeIcP6ZMsoOmgE++osq3mOsVOGAB0DOoY1B3M0NVdsjpw17bAdQiiVSMZK/I9lmzzW4nrvNJv/UJR0X7Vt97SjJWpN4skQBP1thcdolKMjnkBpXzFPL1a0YKFYYJ2RT2SJmwb8umfr0aZWcCU865M7ExRARE7hqmVvQOW6toWbSihHOY6zW4BtHn5hLTSDP61iwDvdj45Jw2QyQW5uulof6/acAA4cW8/9IPTuQj3RS0pWxLk5hiuw2TA+G/f+kxc/v0vOdC1W+A6BIVVupHs9O/+od/P9u1bOouUAWGtK7Ldx4pTPsN1nnwjTzfHyEbn98PMPWDIYafqbElctu2pPjfBLx+3TglWehupGj151eARUGsOqvYyEQy4MDXfSS2YP60ZZ26xuRa08rxNOnI48Go2OjXGxnH2YHaEVO2llcfz4b93YWTf2ALXIag7A8RFSp7bXKFtpa0LikYMjN0fGlKRFW8q7mYkas+gDJMnc5y9Cg4FfEYgQo1zjfGUPHTmGjhprHGtlapg1ZPB9d3VoXkdgUpC3JRmHTNa31tl/Hn/ntl7IlvTT4G1A34FPRjwkTnJdUGPlYY18dMUQM6B2LpFzLrx2uNHvUZmHNkeWkdJW+A6BL3j58Y5CL/kB07Vq9Vz360tbUBV4tzqhCsTST0KK3KAV1b5NdAow5zYONTuhO2aH51LfULVmQoMRXLIZeYcKlrEvu9wkgONy29kSvHXzwExUCrNB6Zc0L102Cpj1bCVnW9iHpigiXWlnEpZVbla6qqyFpKJDaP6Gmrd6BapylmeU/sQAKMH0sypBBl/+ClHIB11KPa7C4iZbd0GzhJVRvcL6IZvKVErILm5sjUg4DNoSLaNkbRWtTPxDFD5azoCYHyuSUaSs0Dns2zMPocTTL/52x2frH8OmFpdMKBVAHQDVLRga++hyyLhFxNz83IgZypyYDZKtzImldgm5tzW18o1OHl9PnzFa2/aqOtr++hA8YqfeeWh6z0sbYHrkPS4L7+h+m1XxJahfOn3ncKXfff0BpRbOgdkN53MVLmuNxgrMVfgVB8HwpDBK87wqWCvW8+PK4ZKBZDGnYNm11CVoU8NFdp/U/XpZ5ZOJq/zYNEAjwocwvj8uH0eg1uDOTuhMx0LPAIiNuOoAWy8CilqT3E6yGWlmJWUyElA5njF6CelOtZyFfjYxUxo1zMCsFYbR2mi8M8KA7Q4TLqMo6GtqvCw5FfuFrSyHYEYwACE5dbodT5Jd6aOJlccQdU6FNmULYxKPURjMiLoipyBsEyf3DFiSOcon1ObCY/5y0j9546VgqU+KUMoTJk7V9xkC+FO+o3KkaNyrpiwi1Q2M8v0c383snk4CcxePxqvAy2fpaPSYPjltmZCNwuMOWCujnHdsQo4UjzfpO2oZd9stWMAbTJL+7qFhAzRpkmaak+fz3U3aAOS2FQLoheACmkLXIekN739hXjUN96MvcsCYk9pozSzymOCrsbDwcMWtnQI+vLvulUZNQFI+3HxaM0xYrQ+lonr34RsAxO3+ejKU+M7DvDeO15nJYqWmqti+j7IGshgXb5PAcyor9Iu0Kxz1FcHeNKxeidpdmWgoK/9kZyNKrm11GbuMJdFwyY0suPpaoG0mxvfN39P1nj+MbiS4Ebgtt/1bu72YW1cV/zjmyubHIeGvvw80Ra4joAoWr28U61IGQb6MxFf9+iX4S1vfd556+udkX77n6VYri//rlvTIsIXUGZJhdl2GNnBiAF23oVJjUj5fV7P3Y7KO6tSvRlg5MDFqUJzKNI4q8tEvNkkT7LlJiW0Mhd6DGiAE8+7i1vgVI++oj6zDiiVNCaXRoNiLQlsJDqiSFu+P618i+sWPK1JXHPPm9c0JD0ONciNyvERuqxHqMSlKtCAlN7pPNMWuI6AujMRYdXV6kEjYocl0O0x+k8OCKvzrx++05JRNxGJOrB+CTUbfMXoCo+jrHbT2KaQ7FyRAPS1Gs3bklqY1gyjyG22VHRVf4CcMFj6jgJIyODLgOgqaeVARRdZRnVq91gaSaCms06vV0CSc67HRlGjYvXOEtX43ZxprKSX6iz+mCosaJHUb7SJRXI0ADdRP3cMGoyoOQVAHokb93FWHeglVR4fV+COGbzcs6P9PCKiFaF6+A4ZcHyUtAWuI6D//OaSp+tLvy85YDABv/fa4i7/1U+8Bd2ZiDf/5gvOef+2lMmo+4prtFXRwKzghVlxzUiQpSyiks291YaTGoDCI+OMS1RbZWXOWWZsyghzr9R8+RwhgRgNpq+qeuP0XS+opSYLLt4mVb4b7UIG99GanObHVrVl58M7hEws9skz/imyYFl5AU6Xn/KWJKa2q/iUJLmmTxurBAlgX/gIhaD7vOaW1KcuVzxcOIHHQlvgOmL6nZ9JYCUAJmQTv27p/JBVz0v2eCZ2Ugjl89CD1WKas5oLXBvKTRsVmAnATDCWkW2mAWq+jACSMFVVTfeuHQusuYwdK1vvOakTCZhH29F7VZoZX+WOzu67/e0luNaEeEafpbykpq3HN5qXTSRaKUdcxXSNYqwscAdud5aBlL7Ziz6YvN+TZMHrACDUehY3pSt+6uY0lqG+KSm9E4MuwGSrW+A6S7Tzya1K8EKj2OeEu0uYlTSpAZ87VLYgNmo3+UJMRRWWbVoCHDTkQlw8D63tc44pKQA5Js+WoZnPIuFw2SXZMX0ONbhyYN28T3LOjdSi+ZhuZtmSHhjwgKV8XqQvi3OmbGv4FZ/3/NdKrx6kyM1Pg3f7/ns3c2tbqoJ9qRwjULtfo4GYFc6afml5Kcu+s+a6kaTrxWo052hj6hIw6/5pjATqfTYI7x2gzrNMW+A6S7Rzx4Cvedwr0H96hV/99eef7+7caemLn3oKYS+/lAsAICxyXFblEm+YK6H+rU4a8jFwjt8yui02UgFQJVkWCYlNHUIjFVcLtKiRiUHcxwUoMvNTEJDjYlAasjApjhITsVxMAIUyDssQK2A030fqSaDkKvb12LFy+2fTk9Ez8UkwaByzErUp57NfTKJq1Tnz2WrLe6bMCUJ6v/OCYh3ACaA25tSqLO/zE7eAA8+q96547U0ZsEy7IllGAhYR1OVnpTtCPeQRETFv6jB64dAdd9yByy+/HJ/4xCdw2WWXne/uNOmxX/WSrT3rAqEv/vunKka9+CQQBqf+yYzebhMSO6quU2AbgG7JKckyAcMuIfZJ6okdkoNHB3APPW53Ax55OwsASbmqPa7LWRJwEhsVlUV/ksJYwZMGSuEYksuwL+2MGLbp56Tnn73Ma5IMc1+nypPqKrC1Y/YpmjyQYWI+MT6f7JpcS16tfgOqNuTAbacHowJlcVqY6kQrcE1UlQKekcr2IX4xI3VLm14tOMPBLXhd8dqbai/JYPrFUMkLTKBF1O1K4md67YdsY0IB+MB3PHe64Q3oMHx8K3GdJeo/+hfnuwtbyrR3N1KPwLBKgeC8pBF4STyWuMVHARLrOo4kkVBMIoW6ZcviVVSIMUk5AQBHYNjJ1zKKJ2CDH9pzlBtVz7rOXGPUdcrss2SF3IYHFOtCX/FQQQ4u1Xg1YqXKc9eXiqzIlyfMuuFPAKVKopk5jhxPBMBHBsEyP6N+mPbYA4Gtf1LFWOqoXNDN/bYu+uM6TKMKFk7EtGDXyQ3lAkxcX2LnqJyfkdQ8dVykLNsdccsMEtvICIuSCqa7dIU4EBAJvCIwB5XGzhdtJa4tHXt6wE2nKo+79z372ma5z7/uNtAA7PxlsnPRgPbWIBm0RAKwkhhxui4sgbBKr86wkyWuvAxM11AlLQy7RnpAOW7blFAKzTkoTLyVckn4XMeIO4WR0ZBjtiSWScpHKgHwLeCx0qAs+hvSFoeyjY91z5+UhHKS4pGtyv4213LHxWFkTeBuxdR9wxagLAAEFBWhBwVOktYoDZPpM0m4QCTwIhoHDyfBWbD2ko2MzT4Poi4UVZ2AElADXAbMqvyUiClkJDvqOKtwqQSnE1JslpQhThua5roIADMhDgTeC0AAPvw9P4zD0lbi2tKdkh704tsQe85BkZm5b7gMY0JxJLAkx0J9DFliU2kGqNVYnmlZNVkGO63fAyXGvMd6C6L6nlbMhAwiHWobRAB4SDty2xgvIobu5mzHZb/LuZZUlZkaZ4CtvPwaACfTQGKLE2kpSwgt7Zm20VLjTUkVVmqwZQWgANDkxa5uAiYzoVuQZdTAMxdw7Mcng29KrPkz5HkS6UvBDlka81KemcwWKNuFhQdr6Zd4b2apS8sxgQLr7w9/3+EB6yhoC1xbOpb04OfdBuwAYUmqRht2GB/8oWdMXkMsSXELD7UMhNgAhB7EiAkU4CK1a9lylNVCku+QKUt3uX5kFaSCVsOOpqCkEhdXjKoAm5UuOIFYR8CKgCXVAckyHqv6tGonRx6YKxuRlWJaK/5GGEHVkN1rrLoWJl3afCokDcJtqNNssLTdcqTO4I42QFlpyRETgySPpbdrrcHHAnrja0lA04IRU8qPaVWH5O6XlSphztlxyPUTYKy+R6IqJAYPodwqBjgSPvRd160Z4LmjLXBt6VgScVLXqUt7cKqX1jUDStLUBjFlZ4pgQAaN8gwQJ2CKHbRdL0VYW5Emw7XMPHW9BgYLWkbKqtKH2T5xSgQrKjZlWJLFRRPQErgfx55Vrvpw31sAZ5m+9slMkDi3EI+l30qdhzEjBfIipDBwjg5obNlo9sDK59n3U5tmjADO908WAVPOGHJ/pVxv6pwCcV9H5SFowNUKjEYSKsBvJC9dgQAjkGVTt5WoMiDVgGfydVaLM0qScgzgSOAlmvfqfNIWuLZ0rOghz76tbOMhWpEJIBL6gmfdBsAAF1RjVZNRyUxVWS36rQqG0NQYpYtQSxfmewvEVFUpzGbEhF1bESCiGmAA3Z5DOssdKfOi6CqR/nRc7DjSv5ZqS/tfnxvtBGyvMzeqSrdkBmVBK43BtunLZ2lKvq9TwW1KHlxGEiUyyLn2RI03+zA0yLXDKIBC+qyYh23U39yfQR5q16+metCNVW6bDJ1zfKPYS9fkJ7zfL7xc6/vAt5dcrA/6pRfjvU98of5+8L+5HqtVl7Sgnz49W+ccbYFrS8eGHvLDt1WSi5WK/vi5bYcMIYoJtMKqrDp99ncryXBH1TYnI5WUVmwkJXNsrPorZUcxSdJEcNKWBzx3jSygKeag6AHgHRSpJ7vEy3cQsv3LDtj1Ne94q5KZZc6OAfpNBtMYMjDCgIn213VeL8q/BbC9M4Idt/alqNBUypJrPSiroQ9jsngg36e8AHVhw0DH2aHBdVXAy183RRNGTo0HtPPrpUY7KAGv5hwbCczMrdRN7h6z1BFJ/z78/c+eGUTqr4Dt/f/Fy1L9IYKI8KBfejFCiMnBQ6Q4JvBc7rM1tAWuLR0bsqmNEnNPDDb286vBuABolRelO4RgmJrdakbc5XkAuK+Z58iZgAHKq2txU5e9vhJQUQVW4NJ/GctobD6ZrLZTrlc7Wswu/sxahlHUhkwxuT5LXUNWp/ZU7F9OzaU81sQw2cS4FZOuPPfYXFwkoVRpLUmMnAZaNCe52vMSMGtjnxoSLGy7+kl1WS1fSyBqZ2Iqc9ExSLJK2MspnbOgw7Z/+xDCqjnz46oa9VJzvlHVOOThdX3VlU/pK0cCDyGB8j76zKuQigZOQAgAg2QcRxX8PjecTWkLXFs6FD34ebepG7jYj8KAytVW4qF0Y0Og8k577wuv0fo+/7m3FSadSVzWBbTiokglAI9AwPdPX8wMMNG8NsmelT3wZpiq8jnDIDl76REDMVAOBKZawjJAJG7hTYcEv3K3DecjVmtVuYtXfTJXW3uWTf3UMzDUbupSn+5TNmerIdR9tr8rY43UbwBmqtpK8pmQzKb6Ihdb9Z0F1VLQtNWQTvy4gARaEujGVOxgrXGIF6M5r/ecUWfeb45l4mSlukQbpDddBFg1pjxLAs5yPgOYutEP5fL7/cLLwVGuIV08iJRGsYSBUAWqXHU5udwfPC3eNo5rSweih/3gKQwnCctLsgQUgLCHsu1HftjfddM1zesf+JLbVGKqrpH3x2SRoBU0ODjuAHHHpDvqGR+4ZtqT0NJDnnMbgNI3/Z7BtjvDuunnaGfhUPoYluUYhyTRcSe5EFHAWuK6DIMf7dUm4BaAKEHGUqaSHrgADAyYd6hUg3EnFoZjKqCBNGkqAkCni9eYXJvqY9CSirrNOnxY3i8qyJbkZcky62phQOUYuXMwbVkmayUuPzc6MVynT/IJcqeyThCKTdA4LXCkZDsyc4w+IvSxqNR8f4CyZ5UwdQED6VvDvme3f2OrqvXE7hyb/pn6mpTHSQTNjCH9AyHFp2XnFOo4BYSvAqiPoMDoupjVfUieh8Spr6ZPtm5ZNBLxCMj4M6fxge95+TaOa0vnjpSfRGi2hnQANZOZoLibwCesGNxYeFUJbxdAd5oUOLrTST3Ii+QCvykNu1D1h0obeSxxADgQutMMktRIRpKLHRV3YZEIAxToEEt/q6S3VNob2bbcPBEjzWVum3ccc+PEACrgqySVDFBgIOTVsCTTHUiN97Q7pN9SvVOLVhxUpDgfItAXxlRsQyklkFXHqcA4ysXHmdlSqafhLDLyZvREyHFGXEBybs8o19+KJKVRBmBRbw2nTX6szu2cLd0zoD1Si4nE1XgvmE1Ze42db6MS1fJiLDVzXY6jLDikj37xUPW33DNaMLCT6hGwiX1M6vXcP85gLHatBNIJwKzdjwJUsur7ApICYLE/uMS1Ba4t7Zse9oOnCkMW/iXPoAWwGUx537Ovxf1vOaUOAZotwTJ58Rx0ElIlyawBSEvvueEaPOiG2wAG3nN9kQQ//7lJElPAidnV3rikU0zu77xAcoXPbWvzXlIEKlt6U/NGbgycNskQRhNW4m7PqDZ79MzPBMJyzwmIslSg6iATyEqBU9aHnMbH24o45JS+EWMHjJYqriExVGVFjdcYP6zu1EtsyhShTNLrhwQkCC71k2Xa9pgFeifBUcdJkoqkUgIAhMUADqFIQXBSiqnLzwEhzWFyVnFq7TymUZiAgJP0gcr9q4OD3acFTDvfro3ilFF1xcwlapUzk9o+VWoyzwsoOarYnZEFtN5vPAwf8m9fpMBFxBi6gwPXVlW4pX3TQ5+WGH3czWoyp9YSacZuETKlMhS6/y2nEHvOakOqedkqSVniSBF3kqT1vufMexIehB769NsQloz+MyjBxSGr/rrkiBH26tW0MLBhh0a2rYqZGjVhVQ6NssQlaW/PycEkjstSBhpkoOdFBO0OCIuIEGJepSfGI04pMYaUvmcZgGUAdmJxezYMiQYTH2cBS+08JcuCMr3ANYPX4FkjEQAlG4NzrKi2hackARUVW81MvTqsUlnpQZT6s4Ss11o7akgOFyEwYpYcKoElq9AE1KwKs0qRhHKuwk5xtrD9AYq60ffZgBZRWkzJ+KqUTZbMYmK0iJC281hBQBDVsVM9enyPIqnnPg2rUFSKWUq1dTEDISRJ633fNp1o/Av+5bPxrm+/easq3NK5obgDgHKwbn7Kxc7q1e+bqA0BlCzcgREXXDPyEwxQAO8l8FqdYPDiLK+3RPqT3XyNtkgyYqTvAA0MJutdWH9KXWoTs5nZgXoVTibJLCfbGzMh7kaTC7GMnVUtlBhxOLFC6BihS5KD2iMiIeyk9B0hRISOMVCXmJIy9szExPaxFwpgjbJEFHfwtbe35WyhzLUAnQWEtIJPjDICtaRlgQNFMgJQ1M7W/lRdYwG0BkkpLlIQZ+mDcn84cAIvC7ZSt6PRIeu4MT5cAZoFsxKMDFXJYQjj2EUDWqPKqfrI9XJ1L6zXj/pp5LI8hLTIAUCLmBcSSHkoVbXKBfAICF1E3xtVyRHTFri2NEtf9I9uU+cLebbfdWpeehKyKrgHvfi2yntwRJxAiTsqsUCZz3AHBTOQSCBnB7hEXRjzbsIi8YGAiARQAIwUltJOCQ9ogZLyhGwXE8cPa2dTxilMJhbeSgBoSSUThnxmaSgshmJPyExE7BClI4TIKRSg62NifH0ETq4Ql11hehFqiBdGDaBOztogMkwySVNGegOqJbxlxoCRsqyUEVglRA2uBmopxGOpTWbr5lPaLXZGrj7VfinTxcjqxyKFEJDV1wZsTZokbcf0qcJNAQuVvlI/VSIzoo7azHL5Kp4rRZyDK5Ex12slUS9JNWyIZK8Haq/SXGdchqRWZoAH4xpstAViA6N8XQhtqc/Sb179fFyOm+cLTdAWuLY0SQ99+m36cP7RzZuBlaX//vJr8ODn34bYA7HnksUdKBJJMK+2U6NY4i577C6SevJDT3vmvvuzCWlmd3EyEKcLoOJCKlVZr7vcz5G2KgsuoiocuWIbUt4idiNOUkRYUQbO0geEBDJdBhqipObS63OFFAjWIkCUgIVCBrGhGOUYKPaKvnioUBdVlTi2M3ENSgQgsjLm5lgbbtkWtMhOhlV9EVBsMXW9rHXZsfoJpgKiBrRaUpMANQUuuGvsVaVObl0+Jiogx7nJkaqwAq/62moMKoGZ/oq9cM6rEHXdAmbqzi515TFFRgqdGMq8Jc1CfW/k+SsxbOn3F/67F+IPv/HFG0zO/mgLXFsakdiw/uA1+wcrT3EnuXnLhooVEbuVNOnStkoey0nK4o4Rd1F72x0Rff5z05Yn1CWGZSWu8sJSBVSVY4r1KHQeeEzIjg5yrYmhgrRBI35DQz4GTm74oNRMzohf5i1dFI2NxxrBiYAQSJmL1k+M0DOGIcKCskg7Sa2ZBhkCYzBShjJIpL6MpI5AZhdlB2x2eipbUSqrq/dcNhCnODm9qO1AQLIQmlr8CAg5DKjAEkXdJQG5pZxpl8pxlaZM/8S2qHir90Hapxpo3JzUdjR251Fi1szYbI5Fm8nCglN1/xtzJOcVVBmgFak97WwtGPdLW+Da0pjEiH0EJMHCEHPJgNo5QbbIgKjpsopqwRk4MmdYCRfj0Yt8VBR7AHlfLQ5UtjHJQMZZNagu8Ejjij00rirsGVWjAByMZAbX/QxqANTVHka9KOAu6sq03xYlW07mwDFnKBCjeKmbi3dcEEYcMKwIoYvKpLp+SAlVTd8Sk4+g3JkYrWMCIOhAQdRCuS+GEUu7Ok7bNSflCHBQiPX1eY4DojqZSOsWaCobkleXiboNKcA6OTaQAqq2LZKoSJ5WsiID+kxqD7Tq2dpLLx+3kq9R4VY5AY3TTfXpVjLaR51P22BR6ypwQpxnLKDleWCkkAkU6ag0VMALkpx5j9o7QZ8nmsk50Ka3vvWt+IZv+Abc6173AhHhl3/5l6vzzIwXvvCF+OzP/mycPHkSV155Jf74j/+4KvPxj38cT3rSk3DZZZfhbne7G5785Cfjk5/85KEGsqWjoz/40WuORNoCTNYMu7AUdYl85vx8CnJU/upcd/nLhJrtMPTfX34N3v2y9Be7pI6UzSGjcbqwdirJJK+ABpQgZRtMbcYzogyMlWSXJU7dSNKVHblBZxKJyktV1rW7HJNVeJYaQorVIav24loZV2USzwsKsa3VHSlSlPwVmw2M44WRCmClrzrJbPLac32zzfm/XM6ORyVTMt8VIMuxlF/PqCqtNKYAVeq0oFUkXC5jq843Om8HYWikjjVjICOV1k4qZu4qabT+EyBjWTBm6bBqT+bfxAJupg89N7Rv4PrUpz6Fhz70ofixH/ux5vmbbroJP/qjP4qf/MmfxNvf/nZceumlePzjH4/Tp0sm4Cc96Ul417vehTe/+c34D//hP+Ctb30rnvKUpxx8FFu6IOk+r74VvOAULCxR+EGYMkqQZHbj5t0ch+RfEAMAH/oHz8SHn/Kss9txAoadDF4GlLQ7WcoSr0r5Lgv0MJQsIlKfHdMIdy1oybYe4sQRxtcXJpTAI3kJDui6qDvZqspHGar0hVVKApJ60bpJ2zQ8avfI16i0K8cFTKQuaycR1ZhJRiu2Mwqpz57pWwZPFRhkSdIBpPyqACNE/UMFJJzbLPekbIRZ5iSY+QuUjzUkEgtaIRRnBPkTKTQEe55HDjNWjepJ7wvc/OQ+hmqRUeZcxlOpO9n+FTWgDZcYkdTfpfeSF4wrfvomXPG6V7Y7fA5p36rCq6++GldffXXzHDPjVa96FZ7//Ofjm77pmwAA//Sf/lPc4x73wC//8i/j27/92/Hud78bt99+O97xjnfgS7/0SwEAr371q/G3/tbfwi233IJ73etehxjOli4oorKZIihrZzoHTlkyIVBJbGucITRLec+VG/jZJO5S26rOBHLy2gxQeTwsoMuotkwRCoPRuOZhsDA3iXGTIYma1GTcUK9Cr0qS74aZCnNMzhnZfgJhfgacCOi6aBgrYcjOGQISESb+y6zOfbtqkI/1il3VeFSYtZ43zFYkl7m72nVRVWsxdiMvdAucXvJjLvNQjsGoAzPwGylJ5qioyyZ6V0lWNbCxkeRq13aubFwgRohADCgB4mZw67zymo4dCqhpsARUcXXlYqTx56TQOfS9qDftXGqoC8M/d+eL9i1xzdEHP/hBfPSjH8WVV16pxy6//HI84hGPwNve9jYAwNve9jbc7W53U9ACgCuvvBIhBLz97W8/yu5s6TzRfX7iFlzxUzdX0gJnlYOAFhOjtS0GgOJ4IKvC/OJ8+O+dZUkr03tuuCb3ufzFvkhfqibsGcMOJ5tYnyW0Rfo+4sgZpCQv4xi0jMpRVKudUU9aqlbttapnjtkJky3ME5VU0GLcQK22IkrBpsGlAdLzQAVMon4L8meZvL1eJcQiqXRdzFJPNCrDWKsEQ91/74ZdqehUymhIk2actcpv7g/uO+CBzJYvv00ZkZwm7puXtrSfWUpinUdbZ30z1EtUpTVoedSPEqIuWMxc5AUlSfzWBWDrOlLnjI9+9KMAgHvc4x7V8Xvc4x567qMf/Sj++l//63Un+h53v/vdtYynM2fO4MyZM/r7jjvuOMpub+mI6UNPTZ5H9/mJW8rBrCIEpReAmLJti0F+5WhJVGKRp8ucBarc3EWNp0DG+hsBiFn9Jfn4KGYvf2WYpS6jadPzyfGD1Zam12UHAI6oGIwGRVumY5hxNQ4R21BAiogRY9DfsjLgLOpZpuUlBuoSkKTfdbvKZPUCA4YhjqU3U6/UZ8E1hIguRAwxZPAKqb7swFFAq/YkamajgAF4CTWwDNzROmkHQAWorfYFpDzw52/ajpcKVdoy4NYeEBUJNtgHyxUzbVOWxFou9+J8w5TfSQHWRcRwuivv4mqDyTnLdCy8Cm+88UbccMMN57sbW9onMXEKlhWXYlHdCCMXALDHrMokmlVeS91xNkmAKQK0SpKUOpSEAjYgAAsADIQdgJZAGFKwsFUdatYLGVceowUxjY8hc67jwl1MPseqq5mBtvL4WaYqKsLk6m6M/CrRcVYb0qie0A1VfX5V3mKu1tZjwQkAlsuuUjGK1NRlb0ciRiefsglhFxFCASArnVUOBw7EOVKxv1G2r0YCZZsgM6lKcjyGzRZMFuQtGAio1g4Qqc4q5o4Y5Fz+RaK1nqICvGxBC3ldpOpNr3WsFx+5cK6vHocGmXflfvWLFfguAOdUT7O63XNERwpc97znPQEAH/vYx/DZn/3ZevxjH/sYHvawh2mZP/uzP6uuW61W+PjHP67Xe7ruuutw7bUlL90dd9yBz/mczznKrm/pLJBuAS/MTpgkC6hxASTV/5iVIyEnfKXmYvI+r74VYS9tnvj+Z0/nLbzvj9yqdbXiUO7zmltGxyXLx+e+7BQCkYJU8SI0CVMzL4g5AQVLzsUV1arBKewVZpo/iUudch6BSmybUCTEVUC3GIwkJbE6VIGFeg+SAFdEl9VwMQZl7AOg18dISaoINfPUQYt05phrS1phJseAjWQSClipU0TuW6CUsYSI0TFlVRbAbFWbBUxLH0pbkUNx5ZdzXZIeCQnYbBovW4fvc6uMVQP6WKkWCYhV4A9U4G8dP8aNZsDynoCoNdDV8wMzJ2wygtgqTH0xBm0n5AVP7KOqCe/zz29UEOSB8KHvum52zEdNRwpc973vfXHPe94Tb3nLWxSo7rjjDrz97W/HU5/6VADAV37lV+Iv/uIv8M53vhMPf/jDAQD/6T/9J8QY8YhHPKJZ7+7uLnZ3d4+yq1s6i3TFa29K0fYtTi1SS5ZcrCoOZLi7gBYVA/t9Xn0rwgqIO5mBq2qN8ICbUpLe4q6eQJEYCgq+O/d5TVJlzgVVcpdUgfI92buKnSCdyJ/ZfZ05tUvEKTHwnCpUJDsqfwbrSz9EOpVyMm/G3pCYaN2p2t5T3Lg7CIgBCDFJwyrFFIYajCNHkWoEBMr9stJYy1GhGous6mXa1CMvXSd2sJCPdXlOJQUUG1VnNZUK2K69/M+qV5M0WJxQbN9qac5KYrWU0gLoUZ+ASkAp8VtOhejUnSNvUHe9lbYqwHbttaRhC3rVfbX3TTwRddHDCCFr7M0cSn+ueN0rU6owYnzwSc/F2aZ9A9cnP/lJvO9979PfH/zgB/H7v//7uPvd74573/ve+KEf+iG89KUvxed+7ufivve9L17wghfgXve6F775m78ZAPDgBz8YV111FX7gB34AP/mTP4nlcomnPe1p+PZv//atR+HFQsKAA+fA48SNiQncmY0OIZ/5TbDSl9m/B0vKKsO8yu+MeosySKwAPpGPcZJ2Sl8c0Nh+rlFBqlSV42Jjnx1MMjhVwciGqaTrUqaLwGOpS1WOHWusmDhjkGWyVrMj12o6qjSviYknNVphtJRVUcVxgM1KW6QZIkYAYXBAScRY7Kx0xe8dF6bIqxPF3V6ALsbkxaiSCdWgZdvwx/ouYoikNi/LsAMVS5E9R8ToOgITleBolegS+lvJR65JziRJXSmQEmFBe3ouPHgI+Ywd1v4nx9ZRDWZZwq7aoAZ4OamUCmqmzB/5XA7KJgIGSfXl2g5dAfbQRQxDSA4fknx3Xtg8Mto3cP3O7/wOHvOYx+hvUeF993d/N173utfh2c9+Nj71qU/hKU95Cv7iL/4Cj3zkI3H77bfjxIkTes3P//zP42lPexq+7uu+DiEEPPGJT8SP/uiPHsFwtnRB0CICHVW/+XSXpIMAgHI8TeCyoaEw4sD48Pc9Wy+9zz+/EUwh7cI6EHhVNxV3E0iEQAm8ZD+swJpfrake/LEkbVEcnaqJErjQyqZ7qrNqKDlvP6BIbLKLs7r3SzzbAkVStP2xQOW/Swxc9tQUJlx5naEwuRaD7ZwKqrbRlLqGIWjWjM6kf7KqRwsgUQz8RhWm0o1rT2O9Qqm3z2rDoGMZc/NAjMGo5CgfA5KqM0iWkNx210VwIE2L6W1RLZDsuqJKFWk0zX9x7xcJFnBSletvHVZQS5p2HtdJqrl3o3tZAWIDOWr7Yvk+UmuaNGKd2SvLbnZZbJZpXvt+0AXKatUhLkOSvgLjQ3/3ORNjODztG7i+5mu+BnNbeBERXvziF+PFL55OrHj3u98dr3/96/fb9JaOCcmW31bvfcXrXpkYvQSSUgpOjYCJE8FIrRE6RtyJ2ZM5JAO2am5YXc8j5XpyHSKV0BqJamqF+IBXnsodyEAoGdmpBjtZwOrYfX1kwCZLXCJBaUaRUMpJ6ih7fTU3BA3mRs9pD6kuJkYbYh5OQMy7C1t3ba2SSuZ15GkLsHaPAkYiMRWpJCXstUzP25hiDPl3AS2R3MRhI8ICq+lXlgTnKDqmLvYwZkKHBF7SVrGtFclxnROGSFrWxpZmNbfdAsxK3VjmZopTFnvYuH17vvXdX5++22vLdwuM0rfWmOu663ODc38v99Tuapyl60Dgc+ABvN1IckvnhO778y9HXHa6Yd8Hv7PWg9/39S8HBcYHzI6pAPCAf/nSpJJYdRiWAfjLRVLVCfMWwJLNBlVdlz9WhO7TAWEv/eYeGHZZnS1oINCS8IFnFOeOz73xFGKXA4g7YDjJNSBZ4UHUeVZdKGpEoxoMS6hakSIlFaHEhC1YJTAPtApYHYpzS8egnbRZ5GIx4MTOEot+wKIbsBw6rIaAVQxYrVLKDwtcXYjYXazQuQwZkQlDJJxZLpQJWgASDzjrrm7rFSaWJLSgALFYDAghos/AMmTpY1QfsfZNATD3SVSaQ95fzIIEAdgx+z4N2TlFHE5WQ1BPSpHU7LjHqY6S1Jdc8WsJKGa1bOQk14jXo51DNuXhvo89+KZBpO7T+FpfTy0te0CScuWeStkY/bXjviz3+nJMpWQe7bfFTFguu/QMZA/EOanrMHz8WLjDb+n4U9ohdWi+GEBK9Np1jbfTvlycs2v49D+iNmMu4BU4qSbzWzucYAUZdeAIWQXo2uWQi5p0T8Jj1CTndER6PhdikfYEwEwTkgFEbVlyPaHSNipgdZxsftkTrl8MWOys0HcRJ3eW6HJQbp8/uxDRx4AlMVaxZMXoMzNedDXDiUBKssuERTcouIQwKMOnXJdVk+l4nPorhCGpm4ix6AcFpSEGDDFUACC0u1hh0Q3JpiSMHgDQlfaHXvsGAItuGEl9ImUNnGxpovKT8zZ7V2ubQ5H6upD6rLtGM2E1dGDiInn56/JxxpSUVMpLfN0mdi177RyAtdqxQJbunQFas0hqO4KkA10GqJaUbSmEiL7P6bkWA1ZLvx3E0dEWuLZ01uiB/8+LVVceupQSggG830lVQFE/eJKtv+/7+penA9YlfKRaJOi2Fqpi41odp27nIuUk9dz9brs1eSGWLag0W30dNVzqbv5WoGpzJNU02e1PRvXlMYYCWrSICD0jdAN2d1fY6VfY6QfsZhASRtKHiMDFAy/EqAxHmPEiRAU0bdKutmNQobLvBpV4gCJZWKrMchmkiBiLrL5UlVo+zkwYKElH0redbkDv7r9ufKnOC8VZgrWtPE4LePlTVH5T0kxZZBR7VQJqFMDOZW1aqJaUZtvdhLx0s+5ab5drBzW3JS7froy3pX60/ZEyJcaNq+tbHpRJEk/3K65T0x+CtsC1pbNG/+Nvv1C/f96/vgEANC8eADzol5IdlBn44299IWaJkRh64JKhIm9PryQM36pgOElUFLMEtUjGdlG5oSugRNECG0pOxYa0pNeY5oq60O23BVNHFsKiJNBtjhGaIkvtWH3y8ttZrHDJ7h52u6GSCIDE6AV3AzEWIaZjFsgoSVwWfIZ8nkkAbiir7VC2PxE13CKUvbkAYDV0Zrdixk6f+taZNgUQRaLqQsTpZWI/fRex26/QU8SKQ0rel6k3YwyU3PPTWIFFFytGGgAMSNIWIYFuFxgxqy0HU6/2FzXYBqrH3Js5TIHQReqS8aojifTBAYLe3pZtLs/NkLeVkT6xu847wbRoTmoqZQCg2McsAHqHDHGDt8HZU16UMmciSSfPT8YD/58Xgwg4ubuH3//6l872fz+0Ba4tnROS1EIWuETHvgmRqPOy2y0FLsfEztLJahHgVQDvJFsWU0A4HVTFx4tYsnbsRIBCAqwTRv1lV7HRoxfUrsU51U6Vf5BSAlcFryxhpXoTOEbJhA9XtdjvekbYGRCyanBnscLJnSV2ugG7fXKtDCgBu5EJHQEDFxVcOsZYUO2MEVCyUQDJVsNmBW3LysiHGLDoiuTDXOxnwFAxNgEtzXiR1byRKasrI5ZDhy571Z3IoJXAhxGlH64vO/2qAlw5t4whAUYMasPpu0GBLakoa6lMrhfJoQsFRPxiwNrFkvSX6kt1FBtdin8jID/jqjaUx8JJObZ927dKeiyPhrt+LHVNqeGT2necwUPq06z41TVSbwFOC1CyEKjHUMCbQ5G6WrbEw9IWuLZ0Tui/f/P1o2PvecKL9l8RodqEUFL2lPOSaSNLUgHAbqyztGf1H5FRB4oE5tV8GZwknyJTdqCQ+m29FZdKP0jrQJG2djmBalZr6gZ9BKBnYBHR7UQsdlZYZNvPicVK1WmdcWvULBPK7CJCnpxVRsuQAaHynKM0B1YaU5WeI5E2pAxQGFOaE9F/FnVelx0yVhlUbDu23xG1rSqAi5SDIjHELEV58PH1EjF6KsHL0hZTeiY6VxZABVp+3PbTXld5Qgr4ZYYuNrzqGhTwmqIp8JqiltPGVOaOWr1Xp9qS47XK0Y0ZGM2zVeFqsHgAImd7H6Wdq//wG6c9zA9KW+Da0vEgu0o0sSzUMTAg573LqhCSL+l7WMSU/WJIK38yKhEQabAz9TmuTKSnrJIEIbn45u/eIUMlLDLmNc9QCGqzirsmCJup1JcDObvdAbu7S1x6Yk8lrIVxlPBSQ08RETXDi0wQ5ZOVzITkGnusM3UCJVO4bcuCwWBUaNYeJaAVUMDJsvFKOsv913YpqRRBGayorSKz8yBSFVDi05JtLo8DRVKyntpWWqrGiKLukzmQ8YlKUtSqXkqL2oe0X4kF1hYAng0aO8+gAlrfB40jm+ibHSczFW/LxiIg3VNkafuItlFv0Ba4tnQsKIRYuJ9IS/mFHLjL25azRu8LwIEJPKSs4ppxwgpK3pORUDwSxdFDJDdfTtNaINvFrMow5VAUhxBecNrhuecCWrkKdQAhICwiTpxY4vJLPlNJWFay2gkrRA4JeMDoQ3LQWMUEPTFniQhuOW6BL3QDVjFg4KBOEcGgrQc1C36BkofhMnZYDl0lJXiPxZ5iAiGQOoSwKS+3IhjmH7INLoCx4lDZ7VZZPSXXAEUi9NKiVfCqClPG5yQ1L1FKPwW4vBOF1GfHwDKObkCggJhtgTYuTWyDLXd5K+G1JF9PrQS5HqDE7mTnJO3bJuVKHywA+9bT/RCg4/FcN8CfzYLiqGkLXFs6Urrvj9yKD/6jZxz4+gf84ksUlNi4cispA0H1Caujt2rEmMvkrByST02pT+DETHX6J+uEYZfp6olR2lEOabuQA4nFyaPskJxzL9r0VoFT3xYx2bP6FU70K1XxATASjIBYAogKjMSbK9ufrJeeZYbiBJF+t7OiC1nQlDoCcQE7Ki73wTE0z+ylDpFabDkv9aQvEZSDnSNIrx1cLBcALEx/ROLU6XVSqkh6lirAcnPgpQ8282Drt3V0OeNGMCq31Aca1dki398WtTwc58qvc2dnlOfE1+rtcZVTjFlAjOaEGA/7D8/HmWWPd3/L9bNj3g9tgWtLR0IPeOUpMAEffPbBQQsAbCoKeTn0hSOuzisRp/2wUFaQABR8rOdh6Fj3t1K1YganwlcMqDDXtixwzj2aV85U7F/1OLIWsGMNMJbjxYkDBbR61oDinX7AInv2qZRTgVaROFRKosIsQpYE+wxuUQLTAK0HXCQXsXVVakGYNiirI83kKnjFvIIfsbo2WZWarODtOSu9WRAiJgQQIiXmGFe9Tqf1zgvElVdiGj+pytK3tU6yaQGxfprzvh6t244JY8BoAY9TClR0UFCydZe6JosBGI/delDKsc7dP3LXMrINsZXl/hC0Ba4tHQl1Z6i4ih+C7BYaMSKZCmQ1Ky+5Sj2scSOcDA8lSwEACpSlrsTIKW86GNGllDVdRByKF57uhCtplzjbuKz3XxSVZP4dANlPTHIR0pD6GxeMuMPgBYOWihzZOQQFtAignLZpd5E8BwGoKqqnWIGWHAOAQFFVhFbyqj3yIiJ3k+DnY7qAIgWJpNeHAavYVQBlJS9xgRZJLozklnKNAGsL7Oy1oq6UY1Je24pBwcOqMaOT6KxEpKpDYCR1AZ6xj89biaIFakpZKvSxZUJdEEmSVSUp7XNrcYa2VOilQVu2VUaymqQ+yflRU1VIgPRX7redw96Ua8X4UW7fx+gdlrbAtaV90+feeCpllMgpjP74umt1/6qD0gN+8SWaKJYZujkgC8OxzhlGDRTd+2BVOZCXpZNz+SXmtMeUSFxEDF1KUnKzrzy2RJXIKNs9RCP9MWWrPIEHQDJ4qKTFAO9G0E5EWETdlFHUoYxkw1sskveguqobxtGHmAHMr+wD+jCozUs4tkoZE2pAAQKvkvKg1lNxChFJzQKIfIotwzuRyGeH2tmjbjuoPc7b1bQtU17VjTPjs6osIIFUyIzVSnXy6T0IO+JKdSZkY9NsW5Ykc4eXYku7UAkwIIFXFTpAPAIpP0oy52y7pcA4wNhf7fMY2jaKEw50vERcgZankQSWP3/n6pc3Sh+OtsC1pf2TqNoY1S6/m9L9fuHlifEY7z6JJSlxH6X8lGfZlOsvmaSu6fracB26suV6CEBEVHWfZWqV9MZGEss2Kk3zmRPapmwcVBLlSiAxAXRiQL9If+KCDJTs6yGnYlpkjzydF8MorS1BvQghmR4SG5ySdCy11GNWmpN6gRIAnOKrKA3WgFernp3sLCKqxQoYpGDDEaF8WttcZ77X16x79Kz9RfvQCkKmkq7Jzk3L3rOJ04RcO2U7tJ6ZAl5JpchVJnqpZzMlbFvtCIwBifXZbmfASH2r+9pyyJibizK2s0Nb4NrSRvR5L7wNAHQLEebkcPDHz7929ror/vHNoEvKXiShY3T9kL2dkvRCQbZsR9mvKRp9fZZqRI2i+nMeR/+LqlGyAEhiUZsM1udy6yQfIRNWq6B90n2KGKl9So4Vgbgkks0u85ylNwxUdtsVdeBOxMlLz5hdioOCxE6/SsG4xDixs8Rut1JmYdV0AayA9enVjnrX9RRxol+q6lDnuSGNhKbERpXjR+SgwNGHiBPdEh1l13eIxMLYi30FpLbdE/0SAYy92OXyqa8rBO0XM6tdbrcrjiiBauCOQ1CPxFFKJwd4XpKS79ZehwCV/iqQQmGyVmpv1enJA0bLsaL0ub5W7GC2nORzZE4B5N4zz/bF5m6sdpWmIk1ZiU28J+cccqY8B6tFHcaLjmFyc8+jpy1wbWkjGk5ySlNEQFhme84mz2Qfc4LdzIz7AX0fC3BlEtUgYNQgGay884MGU4ZYpaMZhpS2pjOpgABSsJKXSFy2mckkjs26+N4wTZM5W5iLXxEXt3vkOLHcR0qOG1gwFieX6LtiG5AsBsmlmrHT76Ejxm6/GqdxAmEv9sXVHbW6S+KdxB60MqDYU9Qg5B1aVfWuuMQYXdLvmdkd9PhOWGE3DApk1salgEqM01ho3TthVQFFdY0Dmg7QfgqoCojKWEU9KVKcHF8wpUz4XEtQol6UOup4JlKAj0RVOzZmzDpyWDCTOuCOAdAsEtqPDPY+Dq4V++QB1J63Ur+WcaDrNRSerL1LHX5kyxaWvpU2rc3QqyxblGx15bsfx9mgLXBtaUT3v+XU6MnjHZScf4ZBryXJUgFRzZk0O102TOdN6GySXbFvMQNwTh8JtNikquG82gyVGq581qq2vou6mo5LeemKPUD+Vquu2tcq9Qtlbqj2WKxc5fN+WWGRgLqyYaDYDCTpbVITDlU/AaMec3FVO91QgYfQSM3YuE8aoJzByasXpQ8LiqlPWRL6TBiAWDJySNkUV0aj9lvkvRW9w0glObKUc9nsmXR1Q5FHYNKhAQhe+jSZRAKPJVR16JiQINbmDQQ0I/6U1NGSaPaTGsmWt5kxRBMxR8kOZ+pSlfMYtLxziz1u6/P1ny1pC9gC15YM3fdVt4IGQlghp0JKYGWTzdKS1OFgHd3nn9+I0BfQktXmkPdIWnQDVpxUg6tlj36xqqQwVcXlt0WcKXTjxLxlRRTJjJI0Y7fGEFVJl7eq6PPWGboqjmm7CsUiytkcOsmqXtzx08vJartJL38HdBEsK3+RuAIQTq7Q7wx5k8cEsD5zQ0esoCXehF4SsOTVg4swaExTR4wFhuwYUGKrPFnQOtEVSSypDQc1sPdhUOeMDhG7YruKtY1tJwzV76qvxhtRXfGR7Dotl/tFbiMl/kVVr8yHLkDCXtp3jLtKCm0BgkhAco896Rw0jrXmb4qsmjcyAfJp6mtdLWrwNA5zfA2QyeLOAkXZtLPdf5vQ16pdZV+zlrelfUbkfnkQ30StelS0Ba4tKYW9vKrqgOHSWPa+ipQ8CFf5Ae0Z6ChtjjhXX1c8kZhzLE4YIJmnRU0nJBsfQlePAIKR0vLmdRag+i6W7TGqTOKt3HM5WWxfmPXdTp7GmVWfskjEgJ1+pRLQp/sF9lY9zqy6nH2gJCVVRtEPGFZpg0wGwD0n9/gA9Hn8wxAQe2u/IE02fKbvcOkuYRE6LGMtdVlHh9556+2ElaZL6sk7NAREzmmgYs5awUHTPPUh4pL+M2rTWnFQ6W3XqOR8jM5uWCFQxIIKM/aAtdsVCW7JASHvp+W9HIsXYazG2Btgi0iqvSmgKPFRXKGBZbLeE9HTSJXX+G7bb0k5tryvLxBjGUvS3ZZXYqX6bfQLQBUzpmVM2bmxrUy2jjMuybWo0AcAy1WHrouaO1K2wQHakrRNheXH1LKRHSVtgetOTlf845sTKA0E2skPeM/gEwM0NdFAyekASQKiFdRz7n6nbsUHri1Bx/f9+ZcXNU5IdihCcvsW3frsKtKtionSRnYCXnbrBaDo6MUzbCquRas3L5u8XLv9CosMKif7pebaQxoiIgN72V5mDeDM2SswO2ToXmE5Vku34MhSne2DrJRjDNhbdfom9tl4KFLVTlhhQWOmkZji+HhijNnVgJN7+jJ2qnbrKbnOLxSgkg2pDwMW2dbUIpHGpOqdMBTbWnZYWYRBARUAQkxOJ32WACNTSUeVQdOOpyMeHZdzrdV8OZbj1KRd40Zvy3nyAddeHduaA6B2tffShu2zkLUxTZFNL2XJu5jbflvbrA3UL3FaYvsNOZcnIeaEzh98Ur0D+Sb0yDc/e1baPJe0Ba4tJVvNomxnj54RTmTGxgBTKLG/gUEciss3Afd5zS3gHQZdskqbHYaonnOyzXekAgSqYpDmswqQAN1OhPKOxkTAYjFUgY7Wy8rGqsy59QrA1aq6IiVIJvMT3bIwwD7ZYAZOjgCeWTET4jKAh5AZepo/6lIWjJC37YhR9ieKOv7U1wJeq6FIiX2I2OlWuGt/Bpf2Z7AbklfmKgYMKKv3yAFLHjscC3gl43sAArAXO5WWggEdYYodJe8+nRvUANER0CHli4yRsBtWWHJQL8QFxSyxrSoACaZ/e7HHMnYma72VkCO8rW1gMkCanUkySI6cMia8E/bDaL1Dh6SaahE5AG3ZxzaRONb1Ly2uynNrgbJlV/K/YySslimX54e+67q1/ZmjX3/sTYe6/ihpC1x3dlqYFfYqG737mBwwRHLqsw2HKAHcqiSzjT3AuxHdXZeV917fFybY2X2c8jFCAbG+L7v4+tQwRJy29OhXul3G3tAVlVtOYmq9oYC0uWHk9MIHgu4lpX0KsbhhG7vE6WEBIEkUfRhw6SKpTtKeToQYA5YDsFx2Sd3HlKTSQOq2nzrOGjvWdQnM+2xn2+1X6t690w1YdAN2woCT/RL3OHkH7tKdwcluiY5itb8WQlKxJeeEkMeXgKOj2qPOOm2sYoeBqVIDimMEAJzsljjZJekOaDPTpZFodsJKy+p8NvzIuhDRI9RMPYOZD4BeZBBbxdKOlTQWtEIcFipp9nk+ilNI2RzTS2yWvCPHlA1QzndgVVvqGPLc2QTAcyRA1k20xyhAVGkQJjBNbLqpHCqpy9qI4xCSRuB0hyoP50VAW+C6k9J9/vmN4IFAosEiNg4GBI5FEtJsEYCqwlh2Du4Z6GvbkwQS20zVoqMf5AXOTFxe6pXbrVaNxiFWoNVnVaO8htzVxm5rHxIpqe8GXLJYKsPpQrKr7HQr7GR3b4lV+vRqB3uxHzGqnW7AXooAQmBC36dxLSMlpsCcJK88l4FKbJqA1k6fHDDusnNGmeOJbqX92A0rXNafxm5YYUEDltnpYEBAh5jAISdlXGZGZL3+vEpNwCQSqVt7Z+6BOCuELMko+FBxlZdj0gcL9D7bhPfMs/MXmbCgmEA8qxCX4rRhmPmkQwRIs+C36rbXRi52r1nPQmOb8RKTvYaIk2Mr1VJZj6iqT92vzam6AzEQS/xay3FEyolDilxvgaxFReoTFXbtGh85q7AHwod/4NnTFR1D2gLXnZQ0/kjcxwmJ+fqVGRsNvagLu/wWg8F9BPVR45+ESS/RKWDZvGdx6BS0Fl3xYBu6wkxFmhIJaqHbqueVuMuj5j2ehHQ1HyIuXeyhp0EzQYgH28luqZ5zykQGxt7Qa909yq7De+jAnABgFTgFyFJIWegHQLYnka1XdH+qLmKnS1LVJf2ecb5Y4WS3xIIidsMSl3R7xaswg5ao6gIxwCnXoo49S1cWdPy8BLACnNxH69AQ/VxyAkgBqnTTs8SIApAdQVWXLUrM2PYl2dMWxlamCxkqNjv1iDTelQBKILSRqNpAWexkuillw3bk+9qq07rFB64BSbTDgVgBzG/OGYjVs7CVZHfK21HqR3N8rG1ZCVJBS2youqi6uKQtYAtcd16iJFFpPFSXNltUBwwBqazuQlbnDWc6cJfdz3cGLHZX2NkZcMnuHs4sUzaFvkvShWzhvugGLIekrjqNkrATqFe9kjRV3MIX3aBbulvqzHsoLzEzYY9LstkBwCWLJRZhwGU7pyvJSuwkYlcJxFjQABBw951PIzLhTOyLTYYD/srup3G6X+DM0OMvz+wmT6yYVDGrFSc7IEf1pFT7FhMCkwKYeGkJc1vkPvUhgerSqMpU0iIYZ4rsdEED4LY2AYq3nmW+CxoqfVY0zF/sSDaWKjIpQC3EHd44bQwcRoBl47isJGZViBEdNAFwYCwm7EerhlqrbOxobXzTUlKaqXrBNVW+9dvTlKu3jwWz7ZVCuY6JunVx5+xWgdLOzTZI2C4EZE7ESUgcMNQGO1Dep+7ioy1w3YmJOkk2y6onJyDZuGLKocerAHQxLeSJ0+7BWU0WOsZiMSCEqBvkyYu80w2q2lOmO3Rqa2o5Uly62FPJahm70QaKNi0RgGpzQjLlgORwkWxHqyaTWpgYpYVu/1GY08luid2wwpnY4zPDAkCPS2gPJ7olTvZLfGa1wJlVj9N9j5QFhHMOxCxpdbFyxrB9A+qkudYpYUBIEg+g4NFlgPUZKABo34VEGvNjtuUiCGeM1JLi3gKW3GGXVpNBxAUITEZ4Z8OaAwfreyEAJuWss0mtOszZ7wmauLYV31b1M0t3FuSEWtkqRmOcqHpwY2vV62mdA4VeL3W6MslbltReKxQVtMSbMDs0xXwsArq7NjGu+Omb8OHvv3jUhVvguhMThfY7GkJK+Cm6cwwBQEzbhHQl9kOT1SK9LKLaC1TsUV3OPTfEiBgIHRt3dOlHfukXebt1H8SaVHtDVq0NClhiyBcmZF/4ZMdaVSmEgKI262lQaUJIpAhVh4EQKWAv90lAeBmLl9+iG5LLvIntEs9AGZsEGleSkdqlYq3qQ21P6oy3XWKqUZ0zZH6Atp3JSyEtO0uX51Z/O7nA9kGcRaQfyCBr9+my160jVf1m2110wGbL2bixdTa1VjubBMfavcpa3oQe9Frza6kF4h6YNh2H9SwUsvasJOVnqY5FlV8+Ns50c0xoC1x3UhLwoewJKGoGHw1PBPCZAO4I1DP63eJNxpGyEwZhJ2eGEMCyW873NGAVAgInVaDdrVbIA1Tl7mwyLOx2K/QU8ZlhgU/GDie6EgUtO/u29pda5OBWiTVaZOCyEkNl08nt9WFAHyOGHJx7stvDmdjj9NBjJ/S4dGeJZRwqo7yAcjGdI8WK5T7shEHHsaBY1HGufQsYInF1aUKwQMmyEbPHTIs5e9tXxcQzoz7ZLSvVmwJKw0VdpVOQfv/L4UQ61nAM8dd6CU3GLCowG2NWPA9racePo0Ve6rKSe4um3NfluAf9AK5SX5U2a/JSWpEuSRd7I5saSlxX6Uf6tMfTIqm8szECIQBEA1bZkQgI+0vRdkxoC1xbqlZtQHKjjZGKjrzLYLYiLOMiqSQoxSrJy3fpTlHz9RSx268UhPZir7FX6Xix9SxlE0TkBLGB0YcVLlucrla9NlOEdYEGDOjlWKwYktppJ6sK79LvVZ53u2FVQIJjsZnlT2u/6ThqHJUw1MgBJ7oVsHNa89HZ+bNSkADabr/KbuTZISSnVhK3dSthCQloCS1owMBBAWOo1H1FAtLrTT8qIGk4cNh5lDYXVBw67L5Ytn8AcMKkUPFAF0EYshoSgNrsrNRi7WlTbu3ihSg2SduXltQpn6uIlH+rQS27WDpepC6rog6hBp6drLb2npEVMIdYJYlWsKKi5ra9U8nYHNf0Vw3wqVWUEheYM+BkJyGw/Lh4aAtcd1L6wHekyPkH/OJL9JjNRK0u8ARU4gMbhw1kt+AuSRCdsY2IbSkBl7zAJY1RK0OCkEg61pHCesUtG15lUmYIxfHgZJecM06GvapuK9m0VskBhXkm7rHMZcXmVsDMq6G8i3jxbhtwostSl8lSsaChKZ1Y0LJg0BmmKqo7P3et+aylhUFBX9puSVkVcBq7ou/TqH+WOKh606oUrVSWwGFQADgT+xGQBJZg5NCcbzsGD7ZluxZuXjdFRdIfS+KF4hgYaVo1q+HUxpljKhaspVYk4iYI+e1MUl5QuugAS2gLXFuqHm5JCyPeSLKdvSTI5ZjtXCEH1fYDdvoBly7OqEfZKnaVgb+nAX1XbFTCND+53MWOeWNXOZg2IHnbJQeJpWZjEG+/wB12wwoneVkxuUv7M5XdRK6zDN4zVsv4KweGzGQXGDBQqLzlVpScTHa7VfYCjOrWf2boNaOETYALALthqJxCOsS6TU4qPw9YIzCYoFa5wajKrOpRHCv0WgPmlYpS+pKZf+fmzLbb3MQyA22gnADY2RFtHR0BgZMKTp1MZOHSRWBYyBKiAq86WW+5F+rMwQQgImAMclPkbWzj83bBUueJtOenpEFQcqSw29GkecgS5dCBAAwoMV2WGDWoqXMVJVvXctnpcXXSuIhoC1x3cgqBNX9ZpXQICZyoY7MxYslyEQJj0Q+464kzuMvOGd1mA4QsURUmIralgOStJ2Bz+c5pjcsRxih2oJPdnr7k1tMuEGM37y0lK3YJ1N0NKw3eFaYrW1wMSO2I2kqY89LERNl9nHZppYC3RFdlsYhhWdRqiMqMVzEkex53qgI9meclJYytvRhFSrQ0OMnGgoctU3kZYuyuLtfKbwHFIc8XUDtiCLAt1GlFPBstuAwYUPIMWgDzfbD9BCV755I7zfxh7ykASK5BBUGqM+kDOYFv7BA5jlJdaUZ74/EIQrUnWY9hJOV7J4wpKU7q9CA2Pl+rGackOxsbNpVWSuuX/J4xIGYgt7FftbQl2/IAe8i2645TvtGLiLbAdSehh//K8wCUFEgAcGbVgahXl3gMKYWN7N5LIb04HJAyr4fk/i6JPy/Z3cNlu6dxSb9XJVcdmDSFD5D2vwJE5TLeikOusXnvphiEOiUYCSJt5RFwIiwnGW9K25PBlGsGq4zDMHwLGBGkKi8gJHAMtf1n4IBIVPoXClhVQOU8GVPfrBrKZmcv4DtMMDdvE/MqxMoDsEF2HqW8BfxUpqh15XgrR+IU2VRU49wXxYtSwFWcNUaJeLNH6MDUDIqakoysRBZNnFvMwGodgdapEKecOA5DIlV6NfMmZOO2fIJp3QGZeNLF/7jSFrguYnr0rz4LyxiwHNLWxZGBGIO+84TygPd9xAoAIiFypx6Hkjsw5C1FdnvZ8iDi8t3TuNvOZ3DSJKYFgJ0QNaO5lWKAxDx2qHgm7pr8eGdieRy93UeZK5U6RUIA0up+IdKWifdSaYUDOhqSuojqlbeqv4zKKzhAEJUXKGKRAaiSaHLmhCW6BF45ZZM4jUj5lmPFOBarqKBkPMhSUgvAWio6D2gwfdXjRsqyasnm/M2Ql7ZUrWiv5aCgpZ6FOTuHHEvlSgxbciIxDhCQsIRkI0wLhvF8SHkJru6R3ehR251WsVbtbULrga3tSj8lybXsWP4aK2EJETDKnCFUJZ4OufDm64xjQVvguogpcvIuklgjAJrcdYhJ9ZDy6VG2VyWPpBVSRnbZm2enl+wJeRuQboXdfoXP2v1kdusuUoTNZycvqmXM1o4i54RpfjruYMldUrnl9EKLMFRl7LXiYWclLEtF6op6zW7u10DZDiVAAlIVWrpGbBFSB7AblhVTl1ldZBWa9AkoUpj0z3reWfApjLOWcuyxQBELuY4DIpl4Knu/DaAPmYF6Z4pAQ3Zlr21ePt2SnT/7Xca9zu5myy+5w5I7nI6LsfOKTV8lUjUNSYXLBbxQSUWiWu6qnIsivdtchel4usdnhl6lq2l1YJ253tqopjwHfR02gfBcQPZssLbxVrXu71POFiXTTdl6J0YqIV3dxWXk2gLXRUyLrmSEkJfwzKrXY5IXsGyVkOKRhj7g5M5SUzZdstjTHH8BjBP9Epd2e7jr4vTITrKgobLbWPftUQYIMBYmy/hdKeJ0XGBJXcVUBTCE4QuDt6DlV+6eAQmpOzYYMdunOkQEhJH9Q7I7RCeBlbpYQSukASmJDcjajVL/SJmw2Iuqe2ZAWDO/I2bPziTxhcaKHkCWKiccNJw60M6vHQ/cb5nz1txMkQXgOXhrgYGEJsjCpxWc3OpPGmdbGrWSmfcsjDyWhNbZsmx/R+3RtLfsFE3ZuuziT/abYyJw/p6k1lo9CEA3Ow2BwfJ3ceHWFrguBnr0rz6riiESgFnkuBNZuXUhIvTlCd6L4nmUYkrs6tKmX0q5/lbqnp62wdjDbljpiyUAsAgDdrMqUIJUK9du82J7W9RAiZEvc3Z2AbYTub4FkmOBleAWNHZLn2IcAm5dBoMBwTDzAUvqXHmRDmswsYCzoFUBPVHrAQAVJ4gEXKvcXnKuTx56Y9uVHc8SxvuOC3hFBzBqf8qS09T4o5EQ/T3x0l513eR81tdM2eHmqOU27oOUpY++L0WymbDfTYxJbUooLvqb2pWmHC72c70tPxVO0WqzbK4qz2wNXqI+BIoGBQCGVQBttzXZ0oVGknpI0hvZeKo+qxxkK4/QLVUPv4uyzfqKg6ZIki025CW72+LTKkktaMAlYU+33QBqjz2rdlLHCLc674yNSyQK+0Ivqcen444C28KUt9/T9ZstJQW0FmR26EUE2NrV6rp2jXoveS4WwJU+79AqJZ2l4uYt7fxlPJnHFBTYgeSYDQcwohIUT8VaEspB0lntE7LUZoFiMBKmqAnTGBPI6b2ZcbEXjziRCjtnC1TXeFhpkPX+y/faPki6/1aHlNl+isnbbVzsXBapi5oAln6L6pHUs3A8vhJMbAOaRxIWYbRRpc5l/r1CQL1YagPO1G//PWavQYkBs0SmjAQ0DzLElrqxizixWKW96kKH5V5/sXnDb4HrYqDP5KzsQAIqmEwOqwxaVkXSS3Cwe5w1mwNFXNqfURXXblipx16qo16p72Z1mI8NAtobDFrHBiADCMq2FUse+55ZKSsx4HG9kbKLNrnsFxkIAhLDsi7sth+7YTlyFY9MWHKf7DqZ6Z2gZVVfymhRGIi0cdfwGUQE7HEdUGsBx6tALWhJPy3oSblorm3GVRkbl/UwFPCaAgABr/K9SI1VvJJVn3IB4pbk1mVHilRnLXGUOQn6qeey84acq+1NM5Kll9ayvUtsXQMXxw0JTPbXWPBr9Vnd7rPEVqcZs3M4I8miBj1RGa44aGon0aCIRmSQY51oUuLIq3CR3/cBycaddhOf7MaxpC1wHXP6/F++HiF0GHiFwCV7diDZ/I7hs30H4kr1J8fsth8WrBJ4LTM4FftMsvGsVBWWPP7CiJFa6jLjH+29lEFQ4qp2Qx1k6lMflawLtp5BJZ+BCZJ/vMQ1jXPohQoQxgw2AUfN1DwIpmPjse7QgAG2HrcbsGH2IlmWYN5Ygy/FynlErp1U4xkAkt8tu5YCQLbnCShWc+SkXgDF8SHb3wbusI7EzV2+b0Kt/b7m4qP8YkzVkCZ0odpEU+eiOEKMJaW6TEVmPKO0UQ1qSYq+TQtQvqyQePYCAZFLgme/a7L4ZlxkuLUFruNOwxDSfjzGA2mRg14lS7ndZl0e/o6SrQoo7t+iplInCwdaibGPGbnUt6CVifPpErN2Hl8naFkx6OKFFzIYRCx4wA4P1fW2Hck40RmGmtRjhSlIoPFe3iK5qPdESgBAwUlKsVJTneaFesSpgwkSaO2oU0g7xkpdyVW9F9EhYA/9iIuI2/sl4cyoHrlnloEndeAYKLoMQiIdRQ4pOwMRLslpr/yCwQYUn+GA3bCsmK7U1fKo81IgJpi1DYgWN3hfV5UJwzFsnznfzkMrD+E6W5HWW7XTluC8Z6UFvXQs32tYaXC9I8dU0DHJYjJnavHu7jpPKOnWfEJe+S0xXoxpl/vjSlvgOuZkAw1FFy7HxeblmcHJbpnsWcaNvXjo1RkUyl5VIQPACovcTgKQlWMAqKSGBVknh2LjKRsZGrBBzUzl+qpuJzEsjY2q2HESQCWpMHmkjbfrSKBid/FNUBtVahRpwtvqitt1sgO1wMsyvAoc4RwhMvh1WYJbOkAS0KrCAXIfQTHfi8J0xQMz9XNA6ErYQQUAORxAPRRHsW1FbSn32qtfPU05a3QUsYyLPC9cZcyQ+ZfM8d7eWdc3dtrwpN6CLgxA6itB2k5VTkDygRzHro2f7yxFc6qnowGB075h3tnDO14USdlnfx+DVHUOdu8tkSJrstcPMW/9s+oAJnzo/75ubtqOHW2B65jSg//N9QCAruPRaniRQauntNeTbliYV4w7JjUSULuxezuIjSWSsju0qlRlFZMGwcsCrXRE/ri93lOddigquPj6WpQYbq0W8kAzypyePfg0LZFRi9UxaU7imBiLMr5cl3gYpmNhJNH4IGFwW5Kx1ErRJB6P0vdKEmBRHRrpx1zr+yR1WHtTJXmZ2+YlF1lQRB7HbElfYtpbRxdZ+8ke0axz4rxVG7bLTDh2NGLmLKB1xIhkbEkNJ4tWELL8ngokbs2Dhq9U5Txwpd253/vEF7YHegHQa977TQe+dgtcx5SIYLaDz/Eb+VxAcrDYyXs+6d5PZgNFCfAFCnDZZKmdSgJ1DNMOrbK6bxgBz15mTHvGAw9IOdOEbDaGDrV0FhHUq61iftKXCacMKZ9ANreDoOqrDrUkIeSZVFQpKwHWjrqwZwlGJEwUQG85mUj7Je5K7Eip3hNhqQ4blURTOXAUwCnbrgSTs1EcMDC6R2V8Kev6KDsFCpOXtLWp/qLitSSS11Qm+kr9ZhYVMqdlftopn2xdUod4EQqJtDW2+4yvt2q4qUDhQHlnZTkmxqAGNb0UedxWugeh1E2oJKsCyvN2Ot/v2nvRjAUpkYCEswgRMZbLHsNqf/Fk55rsBqb7vvYI+7Glc0hdiNhdrNRjsCNOLvEUcbJfYqcr24pIjjcLWpqI1qjxqu/mz9t3ggEbKS9UbFaFvEt1C4S6rLoLGWhO+1W7BO5me5EAnTohWJsZ1c4NU2otH9CsNhoWZ4vk7GEBxdq2OjcPMo6U1SJq3FUl3REXlWT2ghR1a4eIPWNNb3poUk4SnMe/5K6yQS651/vz6bijti1fj/dU9OcFTFvnrHpVVJl1IHrxNqy/Z7UzjT0h03XFkcWr2FqqN2nLZ+EXpjZ2eqgzx/dVguNQ2R6n1JWt/IneXrWKXdOGVfpevD5HZYxzUAQqd3dqgPYqBsQYXIBxyppxEKn1XNLU87cJbYHrGNGXvuG5WA3ppek71swWfYjJpkUxx3MV9eBuWKXNCsEVaLWACyjA4oNSZdXdVSvboA4ZxeusT4x7QuWyM7HKGq/mx4ldbbsWvPZD1RYnUwGqZPL0GTvYJi+adTbRNsVxgUs9qU4eq1zFHV8cO6ReZ1+S35rL0PRdyOdArJ0pxmCbri/SpZeoK6DmPkmjTpUpv8uiYey0seROY8s8tdzfWxSZ8JoveT0A4O+/87uc2m7QzRfnyDtaaN05+N1KUKnN6UDnViD0yCHDnJ/LCA9Ym1jtbShjsqrBBGh1fR7kLkT6gQf+Cp6Jyw907Ra4jhEtugGLblBDrkpZ2QlDdtntQ9REpLtdCjKWnXZt7jwLTuLeDnjwGjNrcZ8WtVzIth+RdhJ4STyUcy02YNMCHQGkDkHjbiypvSSDl/TdqiC9OrFl/2mRlcJsrBoA4/Y8zjIxBZ4tMLb1yDirdmz/KdUxpSYD6vgqAUVh/JdmL0Wpw4KRzKOOvQL0OJIm7Xx3lNJlCRC31Hzg8aJCgqPF3tVSP5aytSpNQAoA/uHvfUetSmvcV8mMsY5a4DUwISABn6Qvk7ivdSSOIWPnC3nmi+S4ztMwkHXbLwHIMfdvlXc6JuIq9RMDIEpuN/f5ZzcCTKAu4oNPeu7a/h8X2gLXMaK77pxJ+mwOGGIAUdqYcZG3qN/pRNKK2M3ZL/pQsjxYT8EUSLuafBmt/UttJFUMV8COWe3b1aSXaCyDFCnNlrPnWnFgUwyueP+JLYtHzFgyv1eqScMf7JjGc1BAfexp5sDRSX+jMTh1pPX88/kApZ6ibgRsMtqqXa0v3WNx3d/J8XWy91iSHJ1qFjXIJ2Dtq5gtkWR2sMReTmor1wVKXo32ngnItuLfJGPL6exhKP2QDSTlt+zZ5e1cpZ7NVGBejee9CFtqvhF46H0rW++M2sk2MpHQ2umc2t6EUs6DZ6ykqXQfQvYuTJkzgnoOpzqKFJYcM5K6EExASAHLFxNtgesY0SV9slesTAb1Pqv/dnR3XQGuVeWEIemabIZzm25pU31zyRaxyl5r2XAPqhhYS7qRsiObEqZ31bVUbFKF4VYqqUZ7agebGEug4hRi2/DfbRst0LLtTVELHKfy+6VtWga1h3nHCtmvarSLMkhtkT5Gzfa1HOdmOTlnvUYrJxfnFSjSoaR1ao2nkuq0P/V+YGl8g+5tZjPKWwrEuOb3/g4AoA9jgJtU6xmbm7QfiHVnZY27I9kNwdm0VI3sNAmUHJkkM31L6pJyqX/1RpM+0W7LeUP2wSMAIcdvCUkqqJQZPu2bBwCRgBA2e7fPNf2z9z32wNdugesY0SX9Mqsishu7xmhF3Ra+7FdUVIM+A4aQdbqw8VgSnyIMzUoAAlo7NGCBQW1WA6iK27EMyjpxdNmug1zjXGJWy2RbGRSkzjkSMBmpD60NJzMrkd7WueRP9XE/1NxXK9u4RLWX2k2qN7v5pbW32awiQ2aoNl5uqq/elVtJ9sNqhDq0vBMteclYJGFRG/sdk+Uau21NNIsTa3e75vf+jrF9lWc3ZYhJGVc8GMzvLGy0ABzyHBSJaU4tOJduKmQ16JRzRynH+unVnmt3RM71itoQgMlhGFKC3eyKuiKg7wcsl+szm5xL+pn/8dVrQ1nmaAtcx4gu7c+oCy9QcgsC6YHfDcvK+ULoLt3pBGA5a0WLpJ4knUUFJMv4LfAU77okDewZ9Y9ICB60hCxDtQHEUxKLz9Zh65xLeVRy9BV3bjsWS6KWa4GXV0HOkZXymurNDEqt+5DaYQArlW6KE4Zk3BdPxJRm60RISZP3shv7jitn74HeU8e0K6LaCaW1RYmXzpdYVL/FDT8iYuD63FTqqbKImN6IsbcqzAaIFg/EspFkqnu86KhsoiSeqWmUvSsHpPeregYbc6gbnRr3+t6AS8j5PIuNt3bb16zvXCf5Hfc9qQwFrOz4QzcgUECgHC7TxRy0PFndeaNN36kWbYHrmNDTfvc7cbJjrEjc1AvQiGRls14IWCXA2tMVdPEmrFflVkISJllLR+VBs8xMEtBKPWW1HyvAsPUJEDXrF1WNV2kZxqHg1hCCWqCk/Xaeenbctv9qN2vU7cmnrpojkUCSU0ZbgktemkFzHKbrigu6qA5T27XUFCFOMmme6lyHteNNOr85NxN1MFCYfjSSYT3OqE4bHbONFIP3YrW0STC51iPOCl4izXt32Y0k284bdYYOAa+iNswZT8j2udTT8kas6y42vUg1QMkSQGKvVujGklcTbGs1IptyZI6nbDqMLgBEA07vLRCHCyum63Rc4PQhtlrZAtcFSi/9o6/H87/gP+hv2WZEgMqmarIr2ADGJd0ZXBL2cIL2qrijVH5VSU7CsPeMXUK9Cx0IeElkmdVarfyFlT1rnysr2+5I9YTaI25ss2ms4h15achLeSOV14T33dw1U+e9nazpxEHFY6/lwm7jqNptebvVWO0nZNVewQF7SXFVFixzNjlPJbMG4wzGGSusm/nc3G2SSWMyP+GozbLg83Moc+HnLzb6btXq1Ryi7PUlbvnilAGgcgwSaSxwvdOy2Lt0XGjbyuaATuLAAkq+wvNNr33vowAAp3mByD1Wh+jUFrguULI7AwMJuICyq7EwE7sp4G5Y4gQtcff+k9WKXuxP/hiM2myR95VSCQyEAV0Odk3XLVFW6S11mqXOqBoHdBXDntpEUc570Ig8rXqzczE6biSLqWunnClaqsTOMfJN+m6vLxKAud5NQQ3UtZt7BTwT0qa3S6Y6y87CVX8bTjKA9VSsFwo+VECSIAdE7KFXG5P0W6TxZnb9hpNO6x7V+74Fc3yJmNWj4gRhz0fT1+jmVJm8kdJgrrRB42LvBaYkN7FJJZhQkqLks12MbVgpNMLdIANMaZ5EM9IOi7AknoVAalvymJ5vSgmr+31J+lO0Ba4LlCIHvPSPvh6yn5Ikm5XVn0hXiyABwAMu7z6FE2GZpCxRyWWQaGW3WDqV26QkYaSnBUxqKAN0I3fwDJSaesm13aLWOclCMeUdOKVysozW19sKEm6NwR6v2kQj6wYKqPt6fG7FQLUKtSV1dcbpxfJLDT+AAEeOz8q2SbF3AeNNN6s2KhA0alKrovQqV6uunbuPeay7YYlPxx19Pu1Go1P7dkl/LHNf0FDtKJ3SY5X4Nskkkvol98B6KZpFntFW2PKjJMI28Fc0EG4+oumnOHeIGtVmjZeteqZsdwDQY0jK9caOzJIaDAiIxJU3oVcfDg60mCln1jh/0PXa9z4Kf766K5Z819TXzLdQKZH3R1vgukDpuof8in5/8R99A4B0wyVOZ5Hd3e3mhieyxOXTDnkHCc8wrTTmJTO5TlSL6wyqm6ymiiv72NNpzn5lr9X2XCCtHds69d0mNOU1aNWp+83esSl16jTQ3qTR7kkmi5M9ADtIWTl0I8o1TGsuQ8Z+xiZJia06bkEDlsBIoth01V059cyMo6X2q3+Pd0lokeQSHB/f0DnHStkzzkOtdkXaA/IClUr2DAGvSISIBEgt9aA4bQBQ9/iuixi6Aff7hZcjhIjFzgrv/pbr9bpH/+qzsOgGXNrv4d896jUb9Xc/9JfxhG4PBIwzuhyEtsB1DMjf6BNhiUvCmcrRQkBLpC3hCzvGmK9MSOwtDUcEn0dQJa2c2qnOu1e/mFYlN8f81IGDAwaMJZ+SgcOo6Li+1noI+jarehrkpSFLk0DVsg9NlPXjaTF/mf8uj2KqnLY1o86zktUOATDPy+m4qFRhrUwmFTnHmDnwks06A6IypvRMFKYqz+6SO2XkPi7Kj09sp6l94+5P7X6M00TFiqmLelDU7V6tqLZDW09DYrNkgcZKXTaUpLJtYawmbNmoxPMwuHLefV49EHMZMpJY1w3GwzLbzEKP1SqNo+8ivvg/Pg8nFitcslhi0aU2Tg89nvAbT8W//uqfaM6zp6//taenxAdhwG630rHLOO9z8v/gku4M/nx5N9w1O4vJwrsDY+8QKsMtcB0DKjErJRVTIMaJsFTbx93CpwGU7BPAmKl1yuTNKr7h5GCZtFUN+lWyVeMt0U0CyAg4q+PtDRF9n5pl2OcEnJYIR+mXJsbrxzAnsXnV6CbUUQkvGDhlH5nK2yf1lowapv9mQdEal6pwncNFldoL46zvfl7sPbZ9qe1JZe8yyzA7ktRGoeyebRYtU5QcHAZVk7eeKxvjFShtY2/VZjbOTY/p9QGyE7NkntA+I1ZegKO+2WB948HowavMjXdaKvFqddb3+nMqnktiNTXAGXXOwi4DmLjKB6aUoKBLSuDl0OGvnPgMTvZL9GHATkjzvIoBn17trHWCsbQ3dBBnlD6DluxCcTLsYckdPj3s4pKwh9NxoR7PJfnBwb0ztsB1DMhKXAsq3oWdkbRawZ8qNc24P/sYHznmY7cstTwIB3QjD0TvHTgFRK1VfeUMMeGZ2Aavsc1Jywpx41ir3EQZT9aWZm1Z66S0jUG2klDHyX59gHUrbdYctexxaSwtoBC1ZaxUvRK7VY0DY3f5KYeQqgyNpeiNXOUplO8ytglnBl8+Gpf0OY89X/cUjaQtF5DcTDe1AWgEYkDjt0KRqOwYnVo0gLHoytYni27ATrfSbDsJaHsF8b/9m38fn17t4Fce/aOj9r/qTT+Mk4tl0ZpkAN2LPXqKuHSxh8v6z2imHksCXAtrvz0gbYHrGJBdVXv14CV0RoNQ4VbXapsyKr2idgu104VbuctOvT4ztsS7+CwZc6unkkGhtq3ZQN2WW7uVDIDpLBseoPdDU1JicwyoM4S06tmUmgsCp3Zt96OMVaQ3oM5uMrAL2Lb3f6ZNeyypfQsw+YBdzTaRk+n6gGCxd4ldw0tYfjE1tbgqOxbX9p/SLypMurHhZtpPTRLlGg3DRPnUl6h5EqdAKzmGGHtU7mcrYW81njUZNVo0zhCffq9iveVLVQ6s6kRxu+8oqm0sEKv0Bkp7jsn+WHtDjzvOnKj68PBfeR76bsDlu2dwl0VK3rwX+9yPnLWkY1zan8FfW3xSJXuR0q3WSKTvTe1/LdoC1zEgu2mjZMC4a/gMLgun9UVfZpfzzq1mfHYA6/nWUQkCtpnhgZrRbPqAWSYUPChyD5tQdi6RbtMZJIOttNPKbt7qS4tacVnF5tSW2Kq6Tdt2HEvuS30Y7wQNJICxiwSRVqUvKn1yCdIuqloeHfMkmUg6UxZAJeHItdY1uW6nZNsXKW9gwo70WSXhqOoyrRspWHqBIWUIQa0i1HkwQFq7+ddLmHRPZK5YgVDnz0hJLclqFystt8jqxzIH4n1Ho/qqDTzN8T7EtAeWSSslgcqeonsHZdxzaaBs5o9yDTUBL2TVnFVtCmAFA1p2w8YCegGhY5yJBQJ2cqjNKrKG3QhdurOHu+ycwb0v/f/jZN7jbckd/uzMXXF6tcBe7PFXdz8N2XE85vsoQCV2y9NxgU+sLsGSO3zqzHYjyYuarM0npfnZwwmTczCpj9w2JJLRwqyUR/UacBjlpGu8W+s8q5rZJvwq3TDSSgpsjFWAxKd7GqklN1DnTfV3kNW8o3VqjFZm+ymwGmWnh3Vr95Jw3b/K3kcATF2tUIQaOGunG22n0X87Dv0+VYbbGTOAOmg6qeBI+53Okymbv088o624KZG8BhgJiMbqU62D6nEHo3a2Ni+1PU2ASkuFOJdT0JYT1/pllkzW5SK0djNro7P1TmbNJ1kMlE1kLaDv5G1afD8WYQAiJhcBb73yZnzzr/8gUuorq1ou/VoEcQqpn6GIBHIv/IJ/X9V5xx134CfwbybnYo62wHUMyKb4uTScwaW0hxO0VAZSB1gmAJOVt+wcLAZRq/6ZsmFZqlSEhhLT35+KzK+sp6I4lMlOeMBVcVSHAK1RXRPzMDs/xqY1R95Ls9kXGrDHXQEpW0aAzUoBDrxE4quuWUMe0Npl0mLBMqx1LvZSp5eQ9PqRfceqortRf9q7B8jcI1/LCtw1cDigsozVLFzsVirrVHrqpNIEkNrT0Nqz7H5caxPx6iLPHGtkCLEOH+KRmKSxYQRAfRg03+kyf6YAaDEpdHXbhn75kT+Ov//O79LrLfDd/jd/RL/f+K6/pfUCwA9//u3N8R2G9q1kfOtb34pv+IZvwL3udS8QEX75l3+5Ov893/M9IKLq76qrrqrKfPzjH8eTnvQkXHbZZbjb3e6GJz/5yfjkJz95qIFczLRDAy4Ne/is7g78je4TuHv3aeyYVS0A7HGHyAGneYHTvBh57YhO2dpGxBNwAOXcYYsk0ntHiSzJ7WDADobsIDLtJl05Ckibrr9JhbCq+igZ5yX7vIBt2l8q/Ym0YYGnZX+S41N/llrH7PFNE+5aEOjAteqTou5fJlvCyLYw9k/G6c+15taSDdCWtvw9kT+f3cOqS/caQCPPg7i8n46L6t7b+iTJ74CQn6feeCLWDFcCk62nGYDqd6VmQ50BxW6KWsZZJBRv87FZZvw9TDsnrHBJ2NMtgVpxUnY+LC1jlyWqsQu8tpG3HfLkY8zSpq9l7AGsf1K+D8mzuAtR/5KdKqpta0otKZvK7sUuv19lYRxB2Bum5Zk/P3Mp/nzvUnx87xJ8fO8SrDigD/WzdklWJZ6glX4/atq3xPWpT30KD33oQ/F93/d9eMITntAsc9VVV+Fnf/Zn9ffu7m51/klPehL+9E//FG9+85uxXC7xvd/7vXjKU56C17/+9b6qOy396/d/MQCkFE5dcsS4lPYq1UFAVptQRAf6/9p7+2hLruo+8Heq3n2vJaEPC9HqbtESksDCsgTGsix6GBNlpNGHFY8JSsbYxIaMFwxyixXAJoxYxBg8thKSlWTZy4aZWWvAKwN2wiwwK8QmAWQJYxoMMjIggUZqS2p9NXJE9Nl679Wtc+aPU/ucvffZp+593S21XvfdWq13b9WpU6fqVp3f2Xv/9t550jFWrPEYMhO1yeTmB41Na181TYuESqEk0ey+AXAsE84yBl+XI7ZhZsZxQklRXn7w/WQSyux1lzXRj2XH4KEE+VorJiswX43WSJ0EG0k0sfsCwMyFpV+IxmcBGU+MTD5A3XZMOMkm+SH1AgYq831F6aL4NE7UmMdnqgFOtyMzXyb3ZIYeVSrWZjh+b3l8GO9TH7PSTKOZLkRgyAQTmTMwpdcyAIIDlihm6WL2eVF3C/VchFYWessnR2xDuc8XAE7H03aeIYRMmWNByP/vf/eR6j6Sf/IjX5jZ5lBlw8B19dVX4+qrrx5ts7Kygm3btpn7vvvd7+Jzn/scvv71r+MnfuInAAC/+7u/i5/+6Z/Gv/pX/wo7duzY6JCOWpm4acyI4bqBlCGJF8vwWFWTS554OQPK8HcwH5PYzmTMN9bD5dgcQDAaOQDK82ofjyRfaNAqNaMe6+QvGc7HgcbK+kHjAWxznhWAPCtbBNfwirgp47uOSxNZSgzA24j5lYcM5GspGaYkY9eWzWyyTS80CIrpakqfHPPj5TiywApNtoN5WzHvWHzYRiVpUKBwBFfcQ2324oBnSWIwDubIBtk8R2mm5hobgQpknFeMyGNxZwZ40vFpzCO+rZofTI+Df0/+r8EHRqC8EbbjkZSD5yOOyM0334ytW7fivPPOw3XXXYdHH3007duzZw9OOeWUBFoAcPnll6NpGnzta18z+1tbW8MTTzwh/h3tcrxbwynNAZzaHhBxD5LmLBlZ7WB+I6CjCPW0fzD3pUBmPWm7rEUJ5hgTaYqT5h0ywWiNKaUkInONMguRKZCPk4+bt6dz6gwfNKaa6CBb+pfG7CTAjGkqqfIzMzvxY+h3oEz8xViUOZOPsQtLIKKH1aYYM4Iwr6Zzo2RsWfFdFIqgr3k9tDHvnZrIPBqshyWRwoffg/g9k36Wh5phW1w259FzAGQTtjbzprEx82c7+GJ4PJBmo9JkzKsm0D4CNi0poHf414VW+KLStat7IfIZMnMfmeJoO52DH6dNoLVzNS4MdPNy7LyNNjeKfZD7yaS40k4FqFma3/NVDjs546qrrsLrX/96nH322di7dy/e+9734uqrr8aePXvQti3279+PrVu3ykEsLeHUU0/F/v37zT5vvPFGfOADHzjcQ31eC/lDIjXXJhKQhrHMsluQpLbDSheIq8VOmWPkxE+AIftaBitAqIggPSBACCC/FgewzOQi31ys4RWP1zFG/WA85Nvo/HQtmd5f15Lo2rRPx0wmDMkwzCmHpAbRw6EhOnzI+RaTqc3NB6T8mqC0kGoMlzPuCbtGDlU1zSr5CF0Z5AsAXSizYlD/+fzSR7QelpAyZ3BNX7EPuaZvaViFVWBIIG1ZB/Q1kfBs8lzGNDDdjhM8ePZ5oAZeOblubKN9xMP5hj+C6g5b0+EAlEDOMM9aIQDk/+LnSJ+TmZCZm4XG9azoModdDjtwveENb0ifL7zwQrziFa/Aueeei5tvvhmXXXbZQfV5ww034F3velf6/sQTT2Dnzp2HPNbnm9x0z3lp0toyECAA++HswQMPJaDIbSxINZSTiCWakJD6Y+emdpYPK/u3Sj+RyYoyXngrQ0TuK/tPaJIZ05CqYKWAoGArstue/Dti5awmVNafXhgI2jtK8619nTb7kMYyRtQwr5WZcvlf3rfMNhJSgDFQY/bJxMbaApD8UTQBDyY4PdFb94JM0Rq8qqY1pl0BKN6JGg2dtlvZ+jWoCN9TMk3KxZ5OrjuWbHfebBni/pE4ySiktoCkqdcCmMU1DvPJs5Fk99mQZ50Of8455+C0007D3Xffjcsuuwzbtm3DI488ItpMp1P84Ac/qPrFVlZWCoLH0SQ33XMeqBTCBBS0S5nfAybMBBP9BE6YVjiRQvhfXJ7CKNhUpGHRZqNB0xnzdXHHfQsJTjLxK3cqS1ZeB2kaitv9ULuIrcYd0jm0GalFj97F6dsCWiBPQFbqGT4ePYFuJBWNiKFSQK0DqFsFbJSdIhFiUMZf0Tn4L8WzZWjwqmpabKEyzzVS8ckYIxUTNcdnKPtQeaLgvL0RGSnkvqH2G4COTT0W/KbAVXDquc1k5CZBAKmESi0HJM90YfXH/Vlc8+GJfDWQEMki9uUUgJNWxqjmDuC5amZS4x35ovLCT1yZtXBFBl7LZJn2w+EPfvL/Ns/7fJVnHbgeeOABPProo9i+fTsAYNeuXXjsscdw66234qKLLgIA3HTTTfDe45JLLnm2h/O8FO6/yRRhH0GMrY6smJFleDRNz162kpEVJ62YvYJ8XAASUFjO8vh3mHBCEKAUJ1dfAKFFUQfkalCfi8yQwFAcEOULSOe0yCQ9ciYNQIJDm6o/e3GtNU1HB0lTpg+eg9BKTSVW18zPJe7LCGhI7ayRgEdt1Dm0BkdtambT9YFpWqP90ziy2XbwK1Jz1wCYYgsF0w7M1LiYahINHsiAmoOm43II4BqBT7/HypCyLIZiuEJbz/2WsVnm/kETimO0tTPun6uJVSWZxLIciGMZUCStrGFkppD9TzVShCZn8CBomYJrSEpsmBsp/yCNhZsJ++Cw5peqmujzWTYMXE899RTuvvvu9P2ee+7BbbfdhlNPPRWnnnoqPvCBD+Daa6/Ftm3bsHfvXvzTf/pP8dKXvhRXXnklAOBHfuRHcNVVV+Etb3kLPvKRj6DrOlx//fV4wxvecEwxCknLAjIBgvuECLQyvbkecd8OK7iu4itJQMLMj2m76yFLKJA21Ih+SDPQMU3mWJj0DLTGVvpWeYsxoTF6tfLWfXJzo1whK00l5CBt06RaserFaxof8zz+qzTGGX0VfSu/mhib4ceyYqnMfgl8hMbI9+fKwMV9V34uwGbI0flbMn06pVnOQV1P5+BmS1dS5g9GNGjVEvZqoaBjQJnxXRwRES1qfi39veZfy2m57GwXOo6NH0d9TUMzF8X9+SYbBq5vfOMb+Lt/9++m7+R7etOb3oQPf/jD+Na3voU/+IM/wGOPPYYdO3bgiiuuwG/+5m8KU9/HP/5xXH/99bjsssvQNA2uvfZa/M7v/M5huJznv3CzoKaH00pz4mzQ4n4t6UcZVsEoY2FIGucxsfwYzKzng0PvAhDapAe1YFk4CgJIPdeYZj+2CMU4W/5yq2sSbLsEfrEdBUlTKfBCC0qaH9cwspaZ2/kC/Og8VDcIAKNG22JpRzVKe5VEY+zbqIxR6a1Fg77HhQ9L+PN6cfxyOo7qccVnpFZ1Od9r/lz43AcDLcsK0CLAM78SB2wPJ0xlvdGmGA+7VzwNVPTTlTkRCcRKn1Hpn6UsFHE/I28ky0EdTBulqdE5SYqUUwq8eB9W3z44/NGu/7N6/s0iGwauSy+9FCHUVx3/+T//55l9nHrqqcdssDF/sbntv3G51PwKAxjOArR8W6tosMqSpcb+Mt2YF27j5g1zEnPRfLCOFpoVyDWcPOHkFydfUxkz1g1sqGyCdKJfuhYPCWIkq2FJnSODjV7dJ0r7DBMQv/86yLNqbmX3TV9jzybKsbgx7Zvh2wSZoWIyJLE0sz40qUx8cQ5LU9HPgDA7OvROJvSlWCn6/Yg9ChcNlFbQc8HSrJjYdEFSHQAvzNIDg5IHFPfIvi6Kw6KaXvmecT8U85txH5mjQGxpJqTSJ1wK6rlaiNG+6WCyjPeDmQzbKTrfYsp8aaKApQIvSwPV5+IMQR6cHEEOo1kxNpMcHVexCSSbBmUSUu4Xor/0kJFwcxuP1h8zO1llLLSJpibcDybMiQEDYcJgEo70p1eAnMovr68ELZokx8SiiY8xEmO/htPeuGfl8TbDs9g2B62Ym7i0WL60MVo/98PxbdyvNctM2YcmERtaNyRLZaauqvYy+Dt195avjZMczDEMoJXGqgAtndNFssM8WmoNDOY5lvufxsyEtWd0dsHMIZOGBebIVHkO5DoLCW0jzSufN9Pip77BH//3vz96rZtJFsD1HMif33suGsdfwEaSJED+rUxqGPNnjU1AdZ+FqwIG/xw1r97uy/WFr2den9SYf2yMms+PNWN/nKwbpo+rnX+WjFGYLZlZ6LBixjuYLBmWhgYwjdbok4gfVhvtC2uD1Dbmou5ThoxBLAp9jotqCi2Xfxa/NVuUFCVNkhZjx6TxNrqd3hfHLE1zXMsaA9tZoinrXOICoe4DtPxa1FemvDfirxYf3FEFWsACuJ4TmQzJY+mFbRGwZfAZ0INJfiYrx1meuJ3UTmZMenoy56Y6sU3NSTWChd5nMQrjeZk5yNmgRSZPDqaSyj/0F8ox0LVNIFly+hy1gFAus8DJAjs+Tm4e7MJSTl1l+L3MLBpKaxrbXxPyL421L1JjGRpc3O/RNLkKst5PfcQ+h+TO4GAgTbfWhM9JMzyBr26jz8czX0RthflYnTwvFw74lB5KCzch0vPL6e9UFLOmdXEw5u/FWuDxalZgdN3nZbENpyEHRGvWIG+/1ND84PF0d/SFEi2A6zmSFgHLbioe/GiGC8XKjySTGcbzo/EJurY6tnwzVhtqtww5caR+DXJBHEOd0cT7oH1j9Y9atc+i0Fs+Mi2J8izAzTaz1KRmZuOECu7b0fR0alMzS82TCLdG/qB+hYlzRHO1tK1YtLJsm6jslbHka6P6UWNmxPHnVy+oLP+WJj/EsWcQGyNiWGItEClbhvUsNy5gCb4Akxzrlc1yVtql9CwPZj8fov/aD0A5Dc2oZsbF9hGWJs1132KtX8Ha9Oib5o++K3qeyJ/fe276PHGSvQdwM0fUNNbn8YnAmZrHmEgfV4NWB+MqjUWbaCyyhRZ6UWraj/4uNBYDUPnEUbte0tC4BtobK9paP4dTakG/GpTm0Z44AI5pT7o8iRZ+b4R5mJE/auMk4TFhGhgEaxQ53nCeOl3F9VT8tXxhQdrWPOmauAhN0XhXauxDDba8aGUcmzO3twOAjQE1Bf1OvdTg5qHuz5tLcOobrPVLWJ0uYX0BXAvZiFDaIvJfARhYTwETBHjkyXuZAUUtZqs2+Woaec3f0w6BzdzkCLAYq+AEYNFx/K89wcRVKt/HV641EKO4M621tQhYZdoSBycLHLnGMQZQ9WDS0qRY08L0/eD1q8qg42nSjrQ5TYOdRbaw2In6OMq2UYCYega0SU+bAQm8NFBl1p7K8hHyRA1njGkwE8bn36cCjen8iV5umxK7ITB2AmkiFKVMKPl0oKrQIf2tCQHVml+SC6SQx2eBg5WCTMZJ5QUhP74LDbo+X+s0NEnr0m1rpVCsBLt0fk424eM50C1jdbqEZ9YnWO+Ovmn+6LuiIyxc04o1qyJYUBwTaVgJtGjl5spAY02B56YvnjG9NrFTWw5CYyu2mK1jfFVXMzXWzBdxXNl0wj9zEJg4L0DMIlYQZZ6udRleANVYnFI5toN3totzKFNjAoSCJODNY3h4BKVPKvpCBjbej8giz0yWAJKWYomp/TJ/FN1rK1kthSzwidzSsnhuxDEGJ4b+Gr29mWLNT0bJFPxcdV1bgoAAQSMAOJrwalWbG9G2RqjQ7VvGBuaxXFqD1MlwiU0oKfEOQcVyEXhxKnznG6xPW3z7f/rgyJ3ZvLIArsMof37vuQVQLMOniZkLB63adw5anq1g5xXS9BoDsCzfVJronXwRa+QMLZb2xEUn+wSQUt8cjIxpoDXWmvVdb7P8hJpQwmWebBcloy2bPOc1Y+o4Ly41ksdMCjxcQTn3DMRkXbf4/PlBa4h5DCkI2GZ88piog2HmHUzmC3mfsv8qapaldpqvqwQySzhAFONVsVR5TNEPpgka1vX54DD1+pll/jIggVdjvG+9b9D1LW77e/+7Of6jQRbAdRhFawmN81hxfdKygGwa1KvUjjlnSbRPawy0ahMraXre5cwb1ReSn98hAaceUw28OKWfVoHU3lpZ0rjS9TtJ4OAvaxuCyBCeYozYuGgMG5FZ5TWsa16v5Lkjc6yONwLq/q2xGDArP+Is3xc37Vn3YlZMF1kD7H1l7r6ejdEsgTIyXj5OrTl3fikGVA8B9Lr21jzCQSrGRLXofJkRwwcn0lkBZD7P/ipba5TmdmrXhxwwLMhXTibW1X1xwJvyhLwo3wcwjY9nqp/6Bs9MJ1g7Cs2DXI7uq3uOJSevbJOJkPuutHCToCAnQMZbEVvMIjMkQkXF/9MI4AvY0pBfQKWRYsPjxJGGnY/T9/U4xIs1mEQR2GcuYZhI2PVMBt8Evye2NlhqUtq/V6fIe1B6oY2aCmdpLjpDRRn8y/pibMOaWBqMBrTamGpEGPo7S2uOx9UTzPYh5ymskTFMokRiGJY5Nb0CXD9oRyIbBu9LUdz1uTnpoh8WhdycxsfUuvj8dkPlBQItLTqtkqmhhXyN9Cx3bLFFCXfbdoqnp8uZYcu6mjJwpSDkxGx0HtPQYOobrPdE7mgH82CLrm+xOl2am8SxWWUBXIdBvnrfSwaWG2lJ8cXcgoAYSVGXmRMicqomHuskxPBtkfhhkhlzWI+Z6gpGIQ03lMcm/xWczPzBJrrDLZJkUgctLjVCgLVdTLDaxMRo8DoLRi0TO98/JlyD8ZAmuUILVuOaTVKRpBZ7MZD/1rKNHAyDECgZoLQw0/krGwPgx1iDYyJSP1WeRZNoYgQj0/dpBdinvh2AUxIsAJm5Q7wvYH4tSDMjgZv1jJLPaxqiebDzDaZ9I/xgR6MsgOswiKZmRw2obJNX41Lb8Gxib11IKzQgr+w4445rPmNmu1nSqNX4WDseDE3jnLV656InOavP1HYOv4aeYPUKuW4K8ymfn/aFWRnj8/hL5lw+TwYvDliiztcskxmbpDkRw8pAYWkZpoamtPYCsKzrNPupj1uzEbWsD8HRHACKqsiDiPsfGqCZmuO1xGKGWlk0erZPZyAhjYlLj2YoaOmKd1GeP2tgQsMKpZans9xbfjFuFlxusoFxqnxnJWi16H2DkXSyR4UsgOswCSdR0MpfazmC2s0+k5mM1/ABkLJOdIjO8JaZOzjZYwJpLqvRzydi5SoBkpsLNSAJVhbbN4G3abrq2OI+MJaVPs8YaHGzF60+deqs2uTMRReWjH2XExb5DqwxMX6jYAZS39X0O5AxWNwXlM5tmgTtCTlnOB8qNCdCRN2/BRezfFjXHPvUpALWhgfSDj61bCKXpA7yUdH3bDkYwBllZv40zkErszL3k1jhCwnUFTmDPlvaG18UTJpMsc9AREmns0amGYZT3yR/GS8bAsRsFyRLLpNALDYgXcdSk2n9oi/fJC2L5zIEgJW2TwHhRzluLYDr2ZBlSP8WFzK3zTKziBxrinjBQaZGHwcUy0+16zWguPm0tFobkcla9d2hMYG8lituXtlIYlOtmdWT8NKYlGZQ0Zg4O5DOTaEKlp9IMxDHtLAERgjpuJpmI9qPaHfch1UTvrCY5c+KGmKcLGV+w3EywzwMQ25CBOq/91jgMSXipcz2Yjx0L3i8GusrmuiythX3K4ZfIPZvBi1Kpi2uN4GVM8yIbgCkkvpfJtUtQSsw8OLhNUezHDXA9eiDZwAAegR0IeCMFz8s9vU0sQzb+kGX5u0OVi4+6z4A0dcFxFX3FtdjxQHd8PyUZAvAhwxIMW3N4LhVefp4VngOXrNo5KM+FgagOnu21sZqIivShkS64FolIE2hQOnz0hOijv+aR3is1+y28/lFqHIyaV1Jm9kAu436GMvqAUhA4uBmmeHG0huZ7VEy9xoFCPq+cbDSvxcHNj6mdC2K/JDHYQXx1oky5AsbC23QMpZkt0iHZWhlgEWeKf1KZEIEMjNxavzG3Ew49W3qigcgc00r+7+y6VOPbwy0QnBHvX8LOIqAi6SFQ+uMlSEcekWWsNod2rkjHfrEpsPxLmDZOQBhAK+gHv+QwBRgL09Aos+TH8knKri0l5PmZVGcLa0L0K+g1P54gHQurFfP4jEhbcNJ4NM511KhSvWdx41Zq/kaM2qjlPd4D9Q5K5Ir/mYTIQXIktlLBwZX/TSIk1otoJWOj+Oqx2jVttHxGkR5nkHLbFgjp0BpTQCwzCbnZddLExnyM8IDjjmJgU/upL3Q9cprmLDJO4/JerYtVuYYu5CDFx07cT2oXpdX4Q3dkIopxX4xOvyaX8rmQDZ/PNMvp8+yiKR0C6z7pbSN6OvT0GLJ9eJ3IRAkgOIBzxq0fHAIAHrfoPelufdolKMOuICodT3y4I5iO4EXMBu0Hnxg+4a1MW22awC0AHzELyENpJZixXBpMx/c4FdyLsU9zTMm6XMy2jAw7MO4n4leOE7S4P0IMYZHZpKNmDK4/440hxqhY2x1rskYup0FOvr4VGhQgW/DAYh8Nw6JKcc1NgFCQfq5dHYMoASteWts1diGvH4T3z4mY/R4LdZzyXP65ZCK0kcVjx/XiGuxcbYpVy6qBIOSWwZYn4kIZZgMJ64fyD1SI5KLrHyNtXeU+72KMUJqVcU1abKH0oBDcCJr/NEomx64Hn3wDDTOoXGDZsKEA1VN9iuAo8epdQ4PPrAdBwLwsp3zA1hm/6k+XbSPy/GFwRQ12yYtKqiqpvOy+3qlodEYCn9X5dyzpNDy0qp/o74ru1Iz9ctjx5KEchLPVXrL6+WfrRIa+rWXZkPZlpfp4FKrHxYXK3UNCpCTsE7ztFGNc0xGY7qCE9kxxHarL6X9jZlk5/VxefaZ/1YbicMTpVDmfZZNCn4T31XE2K+sgSurhDpFrQpzzaJQ8/UWAc9xRMzc6OAD8Nc/85tzXeNmlk0PXNH8R6sbhy54tMYPr7f1IaB1Trw+sk0AnENzELzSfgDMCYCJc0AosxG0LoJbE6i0iZwsaxk2SHSeNP6wU96/eE1KMxk0Kp7Ng8yS68lUI1+0WWKac2as4AlE02d9Ljo8KO1zJA1PMlUxDWqswOTYpN04j1bde2LD6T55JWse6Fs7B+Uk1IQAAarGuLk2xutrgWlyFq0+HV9hXJZxYZJU0KMVfi0PqpTsUi2wHIDdpPtEYybhJtPMvnTpe40Nm0xvw9g5gFm1t1b9pLh3sT92XuGf9eiGYyzTq/bfWb46siKIOC1u6TCANmtl2ZTOTYO6rQ8uBVM3Lmad8cHB9+1Agz82/FvAJgeuHzx0Hk48UZtDovYFRPJDKybTIbntAFokPv3VfqgIMPPKj5+1D39135nRL+UyWPUY2EjQPqYMYH3I+zzyClMn6NSkCe4L09vlebKzXUvD2miyxjwyO6tEPrf0N1hjkaZI0kjnEUq6S6VbNJiMUsSZJM3OTYtro5Losn3UwoTfZsagzcnRmDQ5gFkpoKrZMyymHQNGnaBZxj9lopCmxsffUIGn0FglNX5eIkwt9EBrw+ldVTFYfAyTwXdVE52vcBZNvmP+JrpGnR1D5yKlfukx4ExDbgIkgJr6NuUnpG1/8trfSX393Zt+Nfm12sYnCv56H7NlEGgdC9oWsMmByx0GcoVmGfYo/V9779+OczdgLowvRgTKeQkgLTMl0tRYIy9QADKvmjxP0G48DwMOYiiC/HHzmfeE2XIDYrHRxs6RAIwdUvPHcLNr6oNlHNnQOJXJU7PxeHkPwWTc4OPI62BZ8Vxjx3HZSP4+oPQh2SZNMplS3JnNBLViylIfM0CrTJlkg1dN5kmdVZxzAC3tV9ZBwTyOi8bKSRpk5ZjneS6C7xnJgvdFny2rQsqQ4XNQ83RIptt7d1Qn1LVkUwMXaVfkyyIfF/d1cd/X2ONNfgsPgIedN8O+u+7fjvXQ4EfPfHB0TD9+1j4AwPfu3xEnt8A1Lzcw9zI4pfMHpd2xZzeCqXzY8/hKs9ckrRilyQcAlpkZkRMxlgfmXIOADiV5Qq+8LW2G98vbcpnHDJliWBh4zVMdlmufOsNHOv8GTCmcCGIxNtN4B19axwBtlnDwsdJDWWmdRCaNdK/npOU7L2qHWVJL6USmwZrUMmfIel4yOHne34Hfl3IBMeT+Y9viuO1qwrykCY0vniPfRwIsDlD6b/ytc9xVATbso2cLm0+8+v9K29+w563Z1BcaoW1pTavrW5BdRAQgB8D75pjRsrhsauAi4aClpbadJJnJkMGrRyZXtEBkhwFY38DCvQsNJuixpXFRm3OD6dJ4XzVpA5AaGC86SQ5m75x4OYmeXjMJCvYfM0NS2/XQpIBpTWDg1Zk5aIlrqJqsxk02ItDaAEsCL+03KGn++V4UZlJhIrMXAD1cUWRTAl7JPOwRfTzJl2b4jmpANhmKTOq2FmAVlZQZaPGUS73WVth3HkdW+riamZpDF9qCJLIelhLTzhKeSQPIJraSrSkJFzq8QGxnoQm8DpdoxwDT0qSoDfe58d9izedpUVbjjtT1ZLpPrEN5T5eaPuUr7HxbBWmd8d1iEXZ9eTyZBaf+6M9JWJNNDVyRMViyCWuSTHDOpe8k7UCiADJocQBL9PU5RWa+yH3Y47H70OZDiruij/pctSBRbR4szyM1TO5jm+U7m1dmpYbSwidSPfnocYyZfHh7uWLXWcKl/1AHM+fAb6nxFNekJ1pDOD3+cIip7ahzE/29WtnZMOPWmI+ZGOKLpLSHIrOy9mt/HG8/D8vQArC0r8bwU+ZCa0GogSyfIwPRH+36P4ux5NisuOghTUqfP6jvAI4506CWTQ1cIdQ1LS1Ei+cECJKWnCMDeLXOSZ8XNp5ho3UBE0dU+NivZ88k+ZWs1602nZEpzwOF34ubDDMzzcpwkPe16p5YMmb24+fjPhA6b82sx4FWtAv188wDTrX9BeEDg/aqwKvGapvl87Jy5h2MWNpWHH89ia1uO1dYRJWKL5mDXKximZwdqX1d2tS2kSwoGy0CqsXSqvogK0JbyW8pA4allXehLTJj8OfdVy7PitH8n/f8r/gPu/6P9P31f3EdAKRUTn/ni7+GnkyCAHrf4C+vunHmdR9LsqmBq0fMPmHR32vxW22ajGTbjgFVz8Bw3CswLsuD/y1myZhfuP/L1MZC/B/331BqnnkYfq3BnrTGwDNrJLMiA4AxU5hOzJvOP4AIpbiyqMMbifvaKANyTGb5sgBmpkNe6Vv3fDJkQrAYjfocte+11FKJPs9iwmpBudnX1ycaeQY5bn4cBypuruRASlk6RPthHSgDjOtmV5mwVi4EtLl2XuIGkH1da2FJgCsPMibpQltkp6DtxB6MGTU4QOf3Qmp/MUHAdDAT6mBg/cwemC5jrc81tLjWxbNhLCTLpgauQ5GGs/0CsjYUWOmDQzxHHwJWmibR7/sQij75uQRBhMaJUgNrh4khDR4QjDZdTVloQwNocTOkPif/rll9uq+qyaxCphgDGm3etD7PK2MJfWcRPKyg5/i5nPDpGN4emH+Snb3QGKfIt2zCtEBOmCNTnJ4V2zaeGYPAioMX3zdL7KS7TWFeq4FWHue4xpv60SSgCrDXJIJPK3xa8x7H623Rc/eGPW/FdABMPQLyg+kUTj64haZVkaMCuCyta56sGXSMiN9iZkIu+x7Yji5gblr8y3c+hAcf2C62LStqPAGZBV5cuHmz0MaCos4D6Cpj4qBFxxN4WdpTfsHmSAlkMA2t81syV5DzSBzarP0W8OVMDKVZiMbZMZIG17CKRK6GX2sjmgEX2wc57vvRpsTMFO0LTYjKkPB6ZiJjvhsnamiiCG0bv6bSD6Wft5rvUKeC4veZx6SZFQEME6YmQ/Dx5XNStvcmgUrUxqygcDeYChsBWjFZdg/flz4ruAhkB6bLeGY6SRWPc5CxTJ67kFI2PXDRZJ5yEAqzSwle6XvkdZgiwMQ5rA+U9hOajT1I7WAq5EAiXhGlhc1rTox+MzpHHG8X+H55zUR1p/bzCt3biQOakH1rXCwadx8clp0XbETaZ58nmw5T2xlsRC0zfXGz/G5D5o26qTBqJI2TmgmlexITJJqCYRi3lz6z8jokTZ62zSprQoxCrsWI+ljCRBhEuZCC5ZfuFTMHijuhWI4GCAp/Ucj3LI8t2x7GkhBzUNLtavcxAn0LSqCrY7Iii1KfJ7P61vwEFKPVOI9JQ77g7Du18m3yuK6Ji0HCTdulfQ1jGHo4rA+ARYunwBZf3TFQxfhQZNMDl36Naz4vkrF9qc1ApuiBSNZABIfJQayiOXBO0gqRmJAhgdc8oNWozzELCJ3HTnwbKeUZsKg9aVvCJFi7BsZsnBdcdeyUpBWX2k+t4KQluvYXbcvjrSfx1ZWXrbFsJHVVbCMzRKS8hkx4sl1+Lh5blfxW6Trm9220riwBIkMactXn9NcyQw4A3Q6Ak8YPGzxl8UqtiYb8N0StkGtM8yT51ZpYDvYuCRw6TitmmpFgZ2lXnKjRDYDHzdRWElyugaUabIM2xfcnqnyIRSo5PT6mbQpYQizImhfLA5AtgKsqmx64Dla4j6sxzIOcHs+FanuthoAz52Qa8tf5hWfEAOb/9tCLTZMkYPu1ZsnYy6/H0iCDFh1DsWK1Y2hcs1hr1jg2wihL/cyRdFhXt6XtGzqPMDmFUfNMmtSVP8jKEME1j9h3mfB3ozLLJFd7Bmo+OxrnvFnftcnVGs/8GeTtp80uAlrWWRNmR/XZLPUy8ttGDVkXDq28m2zc+bmWMXxxsegA5KKiQCPq6/FSJTHpgRzbsZS+6WBkUwPXkmtGfVmNiw9QLNgYxHYga19cS8tmu1D4u3oETFyTWH2zhGtoFIRMkpMB5/NwgNCvryZs5MDLvL8JIfllimMrRAyaxMaziiTmylzmLl2fqyaZjl9jgOZzyAlz3IyozY40plkkDz2xTVw/PD951a8rKEcgs8e/rqg4vCpyjbwhc+iVptaxIpIbYtwJzY9Pxl4wAjMRp8/fAw94LjUYK0Fs2a68fouYUfPtxYBom3CT65o5E8RIpr5JJs1pyIl5l1ICYKXBDeY/uqacTaNhWp9LBSNj5pwmLbJ4XFeDEM/TDM+dBzpEIsgz65OUSWMhtmxq4KKUTwROJFybqsV5TcSkIk07OTmvbcJrnMPynJMEPz8HwY4B2bYzHkrb9z2w3SRpkBZGj7M1rrH4L0vmIVHUTIljjC9dybk1gESO207sKyZXKybNIJTU+rOO1delcxum/ll28nSOZPLqhcmI929n26hPSLUJdiy9k84Kb8k8Wh4328lUZIxswLQz4T9jIM4ndm4q7IP0V9VAS2umROP3oX6NVhonosLzBLk6toyfW8eOkaa0pLJbWGPmfbaD+S+CGcQ1RYBszOMBoPMN9lzxL8xrXIiUTQ1cAPNZuQgSzUhS24J5WGEQmsc6vkJ1c/u7hI/LOTzy4I40MXAgmkdqbS3AGjMdzkzPxHaP+742VhBSi/ZV1TSvw1XNNTnWN+irtM2fshhjzcQ1D2jUtIJZgce8KGVbSVC7UdCqn8tVs2RoH5eV5on+WibTWb9HDbS8AehjGlbsy6W/mUBCqZuy6a8ZgDp+q5NHtK819pOBKp6rlW2J9g5Gfw9uEa+1AdnUwNUM/0XxEYiY2Y+DBm2jpLwctGJRcTIPZLMipYdqUILeZM5UN5mZ1whSBo/Z2v/gjjQWniNxjAjBqfGaZEHbacLt4NJ2HrCsQYe0Gh1wTH/FZMNePJ4/0JIam49XgI7XxNqoObQGOhY4JJ9V+h2lj0evoKvstAr4Zyp9HbTGROfeA8Yn3HR9BpnBrM81Q2pmOG0+7NmEHYkVQzt9nAW6Ijemh0crNDZNYbe+A6ykCBwkq4+bVJkJVZn3LJGglTVEYKjX5fL4yWRHGtRSk9/KbP4j9iX9Tkj3SheN9IMpcd236H0DN5xnrV9axGxtQDY1cHEhAIuT9hwBkUzTiq9V6SubQAJhkzJhgAHmuExYvxO06Aa/SZx8xv1LNfCi9kSB5/FcRR8MgHhflCW+SEjr5tPUdCXmmv9IgxaffCg34CyW4SxNqUZIKM1Rsp84tjJbgh6L1fesYoz8WCvQVrdJYDLHc0W+LpJ5jtHn5MDLgZAKQwLyN9W+NcraMXusBFRD4xDN8prtp3MOkg+MtC0LtCwQA+SCRS8I+G/MtaqVVlax7hEztosCpXAp5sqSUuuS8VkpE3yI2TBiva6YLPfJteVqvwsp5agBLi6UL5sLAZBPviUvgIp8UToerKiczIDsqYfOgofHSTvun3tsHCBjjFeY21xp9ufY3wp4HSmxgKwGQrT6lf612drchsdk9FOrgUTSaDZdBbQ0LX5W0liusSXtK8wGopqva/SYEbPlTFMdDNASx48zLFM/5AcaTKAH85ta2t7Y71djF9I4idlXZkPxiTjRjOQibIckuY1BHtJMRiJ7TH0Gr6mPAd/T/lDz9BxbctQAF/mgZJ7BuKLWmpQPAV7Y2CWjzwIrAEAYqPOgDBQOCA2eeGgnegT80I4H0jH7H9yBCVxBte9RfqZEvjxrfafAzNamJMNwzLTITYikjdRy86Xs5QZo1PxaOu9gjQxhiaXp0PmtrPD1mB87E4aMJ6qDlyV6QjxYANVaV5FJnGlR1fpWI9d+sFLTBu3q1MosaZg3yQQHlBM3AXtMexTjzjRZg+K7xtJYaU2rcaEA01QwcsQ/aoEWjXHS9Jigx6qfoA0efnjX9RjifSBChnXPQjKpT4fx9ANorU6X0PUNvD+cGTePDTkqgMsj2rY5gWLi8gqmQZlFvkMQwEKPJDEVAenv0hk56FwxO0aLRvUf/UkxeS+dp3UOaz4koMznjMQN6rtDMIFY988lFptEjhVh165llh9kXlIBFyumivoCbCCxvteCf9PxbG6wJgrrPHrC74ZA3RYB60JbCMVnbV60tC2ucYyxBmf5wmYRC1I/KNMuyaKN4yZVbsKkv9lsKAtOEmNxrAQL913pMiDxvCVlnrQunsEjaiQYYqDydQHjGiUfGwcs67yAXMhYiYYL/5xzmKBHB8Z+hEoB1SBR4SnImPu3pqFF17dY65fQ+Qbr0yVM+6hted9YIaMLGZGjArhIYiyUn8v/1EDGc1laGclYto3jtt+bPv+3h16c4rPyMRL4egRsZfR3IGpnJJ0gQDCqNkqty6u/Oo0NZcfgWTLqKY3qoFFkk2DUX/5diwVa9H3MNEfFHFOcF2s7K3+bRXPX46j5m6htTbOZVYuLi6COG2OyxjxLzDEZsV56vHF7NmOKmMDEgLM1L8r+zs81C8gs6UKLieuTD62WXHfstwEye5K0GBplbTxFPS2m9QHjCw0dp5ZqZ4nxZmuMJVPfYN0vMZKHGzK+R5Oh92UNroXMlqMGuCxCxpjvaMKClxvnWFS7M0FLbzfHMAQRT5jGxjW4OE5bNHjyNFCcpJE5lNQuMwytPIQ6ke6YqW9s+0aTx9ZAi6QWqJrGOWOy36jwvrVmVWMqahKGTQKZnxhRu6aNpHaa1daqbhyPc+jRit9YBlHXAaOBRxeWklan28+6B7HUfSufoUoAtiVWNWMBKsGLZ5+zBnVNrcYF9AOPWI7RJQ2MrpOTKtK9GInr4n3R36lvsd63CZz6BFwN+iEf4Z3X/vpc92EhWTY1cLWD+c2HkuVH2ldi9Dla/WaGH7EJu2C/tBysyGzYh4AOmaRxHGvfkR9piCdr3NB3yDFj1iveqM96NDz91DLzmUWQLH1c3AzZKcJGrSQJgRPPUkFBxDpZrpZ07GAe6eESY7Cm1ent2iTXIpigVWMA8uDhMZagHke6Bubfy2NqEojpLBiWjE3+WooURmMr/3k0LQMIrBRGfXDowhImbjpkgm9yjFYAVsMEDTxaF6J/p5I7kSfWBfK1e7TivHSdxzfr6Ts3VdLigGt+JLbvrUmMxInrxXZ+3Rpw0n0YtC1xrDb1+rIPC5xTVnfEWl1rPvvstG+WSBld32Laj/veFjJbNjVwZQq89G8BJUnDYhoW/RnBy4J5CEne6BHw3x56cdw3+LKSBmf0M3EOEzR49MEzEpuQ4sRonET8gHNR0wp6dU4TrC0eEaw4EUNLWvnql0d9nadEiJWKiccnATYo8e1atFZkOr1Zvje+beyYMamZCDOglcG1dj/z0dlN4oswx2YCBH03cwOagc/2GHSewbjgG3xNIcZtEWhFTbtkEupAY/oNRk1ukMQGK5jbEh14zCsa8/GkbZbVhZkGV9w0aVVcc4paljwmlRgZjqe+qUYX92NRLBd9bxCiGZD5uaa+wfoAWgDgXEDbetz+sx+o3reF1GVTA5clSfNyHj5k0CLNTLclMNNJd0k425C/mlxTI+2KTINWADTJBC2882gQsMamIt2Wa16zwCoePwBVIFMbHSv9UTOzIyihF7FmRqQ2WvjEPCt5bb6GjYGNBqp5NC19HlEGZOT8HNRqZrJ58vHpMaSUTXA2CKUVvPQv9SNAYJ6P3RNJZpD9R7bffL8DN4nlsWStqRliBa0xjt6bQhsd2jEwF1lXGHgSyPBsF5w9mAg7LM5qI+IHDXUaGpHpXfjcXNTGaFsfmpQZg0yG3/mfPrjhcy8ky6YGLs9eMtJeuBTmQ+7bcPxlYCtLvRpkn3m2DB0DxsGL9+UHLSrW3erFPmIbAjHprse4X45o8yTdQLwgwMJwJWKSnZGSiWtQOsO6yGwRctAytdUZ2vlxIrMGA00CMa19cYr+WCYLC6Co3QQeHTPvlfevnCgnQy486p+3qeXWm4c5KCbtEVOrjmeq1cDiFHluFvTM57QRWvusWDFRmFL5tchXBMR7EUkX2UzGhfsKZ/kt+f3mviwCWgoopneg8xMRyMzNgE2IeQpXME37J0PWi34AHSvXYsFKVW4ET9eiKP+xynJsEwkZbQoy7voWnW8i9T04LLXzFgdaSE02NXD1Kh4rmj4CJq6NoCXs85kSr2OrdLZ4LgRWPpSU+tQ3VBBxKLU4aqeT+1aTABPBA5I2T6SNmI5qGD8wVLflQFuy6iymHZ/YaolpeRBwHxw6ZiJKVxIcJnS/Q+5vMkzg2pFtgVMXWsD1hX9rzCHOr62rrOJltVtngss8QtqETshKfdFfIiNwc1+pRRiTvJi4mwJgeLFIOhdlc6/JLOYn7xuAMBfXzJDazKY13JSFw08EGM3KT0imwVpGDGu8E9fDw2HS9OL5JbCzKiDrdE+WWGbnSOqINPiJc4Iez/MOrvslcexa32I6/IvaX/W0C5lTNjVw1YQo8aRx+eG/mswqPEl+KwIZqqlF8siDO0zzoAVKnK7P+6RgaSCClqbTAwRQDlAlUCyR28vJ/mAS1+r0TUUG8IqmQ22p3byi/Vc0MczTx6w2s2jXgKS/z8Oc47IRpmDtGAu0SKzxk/YV29cTIFvEjnnGXcuuzsekUzaNVYKmdvP8FkBmFeoFQQxoDtAxX0VQsmIbWpT4bB3wBUiTUMBxfNbj0pKbTTmpg9PfgejbuvWnf2uu611IXY4K4MprU0nSoEDGSFIYWV1ViBvzpnLScVlaqPgkpZvq0WdfmMu1uaLpUGp5NX8ZVQ+yjA7c0UyG0C6UgMVNg3qSkz4Rrk0Of4fdtbIiqR/m4+jh0IaQNTN1ngnTtizg4eAlrpe1T+bKwuwX2NjnAy2eyin3Y6dxMkkwitxA/qz4f5n2yCpPMmbClCbWIZAVZWBycV0GaHFzW6kZe3ATWgavOuBo/xVPbWXFmfH7qRPl6nvI/VJR+4kalx9AsnW+MDOSWdCitVvPWWIcBglePA0UGiSiRb4euTibhsgiDGz7Uvt8Ssq2eWXTAxeB1WSYyrO2RQ9+kwCgQwQIH8JAkKCVmVPpXJ6dh4syacRxA1t4dg9G7tDH5HFJHxgHrQYwTRCUiDde3aAVDS/WMrgfRq7Qx8gYgDGZKn+XlonzaEOmyhPYSNJDSbawS4rMR8KYV6xy8A0rEyIzQ2i/6Xxj4CSM9ZBp5vz6NGgROFjB4DqTRwxJkMsYrVUTaK2HpQKQquMGN6/Z2hgvjrkaltAi0801xd2KjxPApkCMm/8o2W4z+NQSqIUyAJnMgs/0K+w8MlSCZ87gfrsxFmv6zij9hSUgQIAVMLAIm4C2WQDX4ZBND1wkBDax0nD2DKVKwWgQdR2+Mo/glVh7zolMGIdLXnjGg4k2TyQMP3xO46/4uuZ5zGu5Cuk8Y0arVoEZ3x77lJpXT45oQ+bN5q79V/SVa2f8/KN5BAOqALcR6eHM+5THOn+J+6JvgznISQec6m6x74A8uetJf0xqGfs1bdyq6rsRGavoLNupRZlxHZoglMasfFWaWVjL5sEJGJGIo8x6aAWgbSQWjyoae/WMZlDjJXSwMBEeRjkqgCsH5Hp0waMZzHAT1zI6uBPZ073BQhxj9D3z8EtEu26ACZ5Yd0x0u/0P7sBq8CmOy8qJOGtc85B5e5QZ1gvtSu23cvZxluI8/jGeOWPM7DdxQ9wQnEiAS9/pc52JVgYq14KbaV9tgtVagDCFEfvUybY5aLsWOxVBaT3VlWKki4AYtjGYCLVmpfP4kZmylo2d/65pu2uqv5eOX4IrNbB5zKSxXQRUnQfRMg3WhMyWPDjY0qSADA7NcJ96pa1xaeGx1MTEuV3fFgSNCXpxTsuvpXMfEruQAoulGdKnOlt07xegdXhlUwNXhx4HqJ4ExgAAXsZJREFUQp/MbylzRIgMO48pjneR7CCo7CEye0jrmlcyqWIgbRxCZszJ4N/qQ8A6Mgh1KM2FadyImTPoGohdGMcm63NR3NkEmcjRMsqu7FeRNVQbTVGvaV4tM5XwviYY/A4uv8iayu6DKxLeano8IIFqNLga8jz8Wjl4pTLt5W1JIk1dBAisTwVKeXsZNJx8WwMAxZAOed3iswKv3PcwAPZct8z0Fa/V9n1xTY9XJ/bBYTVMqosEvrBphmBgH5o06fOq0DwrRtFPhchCIRZcc58ok+qszPEkBDQTF0Fp0vSiPbEROUFDlqQxSBlsMYAQ6fW+z88RD/9Y5B98dmVTA9czvocLDda0qUk8NB5NiDb3ZUWDJ7JEDSieeuispJVxE2L38LkAGjQOWHv4HKxs/5sNj50zEx98YHux3/Jl6UwaHHI7Ntf0AYkez7Nn1GKbigrFYXb8lyW2WUqSJuJEPN9LbSXZnae9FknfDgK8xhhtde2imdlGyzKLF8Pw+6Rxq8zu+jp0peUWXgCWHhv3yQFlTBjAmHeqbb7n47XExpLizpMFn4/B2jfmL501Ni4T10fGaygp8EsN3dfBpOfqORtFaRwPLLkey40TGTT4c+2Rk+e6g3iXFjIumxq4VgOA4LA6rHh/7EzJAvz2vhdjNbS48Mxspnvwge3CRKjBi2prUcaNMa2qgUPrGnQPn4spenShx2roMXHN3CZEADjjxQ+nsQFZm0pjHP6OmQa5L8sjap0AsD6sBBtD2zJBi/YlE2tJc59lKrSCkulzg4AusSYly8vCHcsUyH0bs5Lw8gmnBl5WH11oRwF/Xsl8V/YLWbFXDokMUhsT12iiWdWevNdnZITgCWpLf5c9cRf+KTbGxvmkdVpjJtFgo+Pb9LNC5rjRxUUF4Ch4mDQtburjLMHaPbSygiQyR4NUxugZN0ntrAXZQvN6dmRTA9dTYQmdX8JqWDIfwMf8Ck4cknty6YPMJ8g1Lsnik1WSSaboM6AFpAS6QM46fzjFerV6tr0FYjBk2pcnldYABIsSz48TlYeTScoeW82ZTtu0T8yaaMhMSGanCaN78741IGomo7juxF6zGWLkT6tl6iBWoZZ5CRpFyXit1Qz7UywSm0Qt0LJMZTzjA5kEAanJRBLDEBxNvrXUZ0nSaNCLSd4yx1pUd812FNda6YfGp4kqqe/BJEmmSVF4UtH4SejZpYVf521A9cGhM5aCPZpEgy/GCp/uERyVaon+Mx9iHbNExkBkEC63/WFjvy4ky6YGrlfs/C5OOumk6v6feslec7uI9XL6RRtSNI35mobM80mEjyIeTyxCIomQS36MtUiaF8m9gwbGXyGeAR7G53hOmxGoP4tkuYoJKFbUCEIDy+etrHYZYNXMh5RNg6d/ojiumliU+zHti3x6tYmjNqnS91r8EQlttwBqVjkUoAQ3ns4IGPfh5L6lSdDy1uk0Ui3T+CzNy6MFFXrU/kBAxpEVWlTl9ysTL6vYLd2+CB5uBGil2C3IwGQfXErt1LggwG3qm1GzM895yDUuYh3ySsvcLxfbeCyzWzF1AW3wo+dbyMHLpgauQ5VGZKgYFwIiIGtoHNys2C8yQzYhv7RPPLRz7sDmlwxAdu8D2wfm4dCvatcHCTRxbNnfN1bKJDItpVkkO6xLk5VOEcW1M8svVqU3Ky0qxv54sY+OG2MyjvlCqK+xNhqodUol2Xbct2UBhxWjBdiT9cHKmOmWZ5GoUVAszatk1clrb93sfHvaFyWeJQOYaJz8eL24GKPQx3aNqEau49VmxWjpXJhUu4vqdKV+naGtDv40Hxya4DEdQHMBXodfjjngSnWxjNIjADEHyQRTY1cRENRBC8hBxX4wO0Xq+8HFAllnmCCaCONYJGjpYGEtaZtDWn3XCBkcODSI1LJu8G3ad9EJivWQrmfE1Ki3zSPSByMnYs5o7MWkJ5PB9kHWlorHltnkrVyEfL85PoNxqPdTFgnLBLfh8irstrUuF0scKwipTYL62jWRg2tVOvBYy6y4O24iNCs0D2Zlcd8NYJm4gDW/BNJla749ivGKY1davfFedL5NOQm5H7VBwFLbY+pbTIPHOpZw/FLprljIockxB1wWqxCQIDSWHookVUaGnS7KJ4p+dBJns1g0I26EvPESZUIEgH0DyYSIHJyYUaO9A9IXYfmRiutQIGit7mt58Uij02DHSRH8vDxweVJZDMzSsPS1kJ9LEy1iBo9WTJ753pTmvprU/FhjMk+7GhjOHXy8gTCP8hySxEIyGdGyOHiVmU4kK5L7FrmGSyZA8m3RGOA8VpyXmeCH9E8EUFz7l6nMIkGjCRHAcjXwqEnxopLk30uxbS637RGrFtP96YaaXACw3PQRaAm4mh5T5yNgLvxbz4ocM8C1/8Ed4nt83Blx4SCfL540l76b7QL5W+IJ/9tDL07gN6/pkMuZnInomEYWGHgpybpkFKpszM195TF1LQzATFDjlZWFWdEwL/IJk/d5MNR8YJxYwLWYXsA+jUX6qOZlFtaYiByIxsqKWPs2mhWkBnq1zBLCvzXDH7hRmTeBrjiXOkZfP8+cIc5FGp+Id2vS9mbwO+WxhcIESEtAsY197EIbi0l6nq7NDySNHq0LWHJ9emanocFS0+NXbv1H+P2L/p8N34uF2HJMANe+B7ZDp0WK9bEA72IwMC95MiY66W3M6i7Bi/fTVCYcru0dimxxtIoNRbLfex/YbmprAHD7vjNSeRIg5hKssfe4aH+ZJmFYINgMx3kCoWG3RbTQ4iu+uzQe7bcjRz1jFFqmtrEMFDFOajYpI96P7KyPzEBeH0r7FLmpK2/TxSIt0azCMSp3g5j1w+qvCIAe4pfidY1nwY/mOztvo86UYdHgrYByauuZabQGdp2X01XStijgmfnJOHmCZOJ6oMl+rC1Nl7SytKhwvQBFD4dJm02Sa1OpRUUNy6eyKkvNEPAceiwNmtxKO8Vx7Tr+xR1X4fhmHW9/+U3Ve7yQ+WRDDpcbb7wRF198MU488URs3boVr3vd63DnnXeKNqurq9i9ezde+MIX4gUveAGuvfZafP/73xdt9u3bh2uuuQbHH388tm7dine/+92YTqd4NqUf/nn2rweG9E3xH2Vwr+YNDKFgHNJ3Kp3C+7D6ooKTBGy1c80r1NfECEjdyI9LGpEGE8+2k9mwDw7roSlYg4nkwbSOCCQsLmzwWy07j+OHF9uazAggNfGjtvK3UvTUxAKkfmDQ0b9l9IIcwMXazpPmTiIkiGvj/6g9rxnFaeuAJFWkfwLA66AFADwLPe+3Bo4EWtrESGPOvq3ZoDWP6MUEpdiaNFPR5oBfjv+GZLnpXrF/ND6thY3FeFHuwonrs0mRsmwM4EX9kQlzTfm0KPB4GigcIS6WJq7HC5bW8EOTAzht5Smc0K7h+HZ9YM0ePlLOsSwb0rhuueUW7N69GxdffDGm0yne+9734oorrsAdd9yBE044AQDwzne+E//pP/0nfPKTn8TJJ5+M66+/Hq9//evxF3/xFwCAvu9xzTXXYNu2bfjKV76Chx9+GL/0S7+EyWSC3/7t3z78VwhpHuv5fEPPNYGHi0wFyiDfqEDlfjAtNs4lgCr6mndM7Fiq5yUqNCvNzhICqxoz8syKtgUAP3pmztzxV/edOZyrDMrVZjKuUWntqihECQlWfHc/HFsDLO6n49odNR9L96TPT/4sPgHPI6R56XgunaeQi0U5n9f3Jc59CKzDMU0LULFSirhiAeK896xm0rQqSVvUelOYNqbHYWlus9JAxeN8oswD2V9Gn63r7RhDkOYQImMASKbClWaKlWYatTnncXyzjlU/mRnqsZCNiQvh4Jf8f/u3f4utW7filltuwWtf+1o8/vjjeNGLXoRPfOIT+Af/4B8AAL73ve/hR37kR7Bnzx68+tWvxp/+6Z/i7/29v4eHHnoIp59+OgDgIx/5CN7znvfgb//2b7G8vDzzvE888QROPvlkPP7446NxXCR774/xUPpxpEd8kibIyDqcJHOH1IosENHWcQ5AGlCIZdgPWgh/xTRw8b61B4bOw+n8ZKp8wY77ijHOK7ft28nGU6fEc9HMP0sD2+LkdNgp0146lgGd9sdJH4dL5sB0rpDLoABI1ZDJ+b8aYjXe5cFYXPq34oRmgZ+V/dy6P1YuP5u+PRLca5A9eDooMnFyk5285zKuq3puZirlZUs2AuxzkVfYNXJyBT9XLcWVPgfPgUjH6W0+NFj1E/CCk7kveq+lmbBxPpkhW6XF0bg7v4QDfhlrfin5uLrQJMCauB7Ht+tYcVNMmilObFYT6eOAX0lm1sYFvO28W2bet2NBNjqPczkkH9fjjz8OADj11FMBALfeeiu6rsPll1+e2rz85S/HmWeemYBrz549uPDCCxNoAcCVV16J6667Drfffjte9apXFedZW1vD2tpa+v7EE09saJw8owRQviie/x0S8JLWRWASzV00QWkQidvIZ5ZFAhgvY8LPy9ta+wjkZCByGAKhyew4HxtyTES2CXYd1n3jJjy+0rayznMQWmeTSSJ4DCbIZT5hiWtV4OmGScsAsJq5aovrxPecYUPVsDI1uax5mbFtkBk7gPqkbhVy1EUmeTudNT6NB4Dl5xojftD+OOaS+q3Zf7XvaQzDobVgaw5OmnFYA60utKlIKkA0dWVSZPutZL7ZV9YLqwBlyiATINeuuIkymR5pbINvbAuNfZgHlhA1rOOb9aRhtQg4vlnDlqbDFtdh4nq8cOkprIcWB/zKXEHlC5ktBw1c3nu84x3vwGte8xpccMEFAID9+/djeXkZp5xyimh7+umnY//+/akNBy3aT/ssufHGG/GBD3zgYIcKwJ6QuNbTByQabPSBcS0iA5bVh8x2QBO6K7QqLlqjmCVWX4IZyRiLBys8pyOQTYhWvkASPmFbAGcCgSv3jzEHZ2l8AESGDG4irBUspO/F/mEi7MKSOEaPhQfEts4X16kzalSvi/m5uGmRp0HitHFK78QzwZexdRm8ZmlgeSw22WOeRMM10fc7XfNImzLou24SHvOp5Xsnt/XG81AbGz9HDJDv0TRxsThxE/jgklnw+CG1XOO8AK2Jm2LZDXUAXIO+khx5IRuTgwau3bt34zvf+Q6+/OUvH87xmHLDDTfgXe96V/r+xBNPYOfOnSNHSNmY0xjJ50VaFM9YYT121E4cw+p96fRRllmQxDJndorxqNmDJI8+eEbtsg5K0uo4aU62Pyp9FxpZpuV7dYw18dTo+4BtMqxli+AxahZoFedQ2yknHg9C5mU7gPljqeaRjVDdeU5Crila92IMpEQ7RjTI27wJ7pYpcZ7UVvZ5y/vO63nNIwT8fWhVcHKbNKrivMKPXI85s2TiehzfrKOHE8/ExPVYabqhT18QPLa4Lv4ezcH5OxdSykEB1/XXX4/Pfvaz+NKXvoQXvzinQtq2bRvW19fx2GOPCa3r+9//PrZt25ba/OVf/qXoj1iH1EbLysoKVlZWzH0HI5a5hxfU45qVNDBJsx03MXKA40CW+8x+rXlBi4+H+rPYg/wch1OiyWRglSl2X9rP2mvzppbYNh/TDPekR2Qptulcpe+QvrcIKeM9ICfLhmkeRB2nY4Q4mY3BAqItrkt+Hz1hiwS8xjls7V5qPtzPVc26YTD36HguXVhigbgKENh3K/ZOFtnMZsTieo1xzBLdVvxWql1RuDONqbyvgvSDgNZNUzC5H+K2tK+K/h7wy4k6v+qjUVITMrh/Ky4cIzBucR22DACFNt5buqcTNy1CLJrBZzpx06h5hR5Ph9k+/IXMlg0BVwgBb3/72/HpT38aN998M84++2yx/6KLLsJkMsEXv/hFXHvttQCAO++8E/v27cOuXbsAALt27cJv/dZv4ZFHHsHWrVsBAJ///Odx0kkn4fzzzz8c1wQA+N79O2a24WZCQIIXUAeSGsmDg1fsNwxMRXlsg7rWxOWRB3egCwFE329R+sm4HDhEU6EWDlqtC3j5zvqY996/3dSQij7N7yH7rYx2uj+dYxHgjLH57kFNq5B9+BTPZR0PIJXz2EiQMokFWmQmpP1ceMViyuYAxEkztZkRrM2zyNtjGmPlzTZ9WmmiYr+2dh2BRxrha0A5tjhonMdkeI4O+GWxnZM4Ul8idssXrD8R10d+MNKkkEGRi71Y84lMM3FTTGaUnFnIfLIh4Nq9ezc+8YlP4DOf+QxOPPHE5JM6+eSTcdxxx+Hkk0/GL//yL+Nd73oXTj31VJx00kl4+9vfjl27duHVr341AOCKK67A+eefj1/8xV/Ehz70Iezfvx/ve9/7sHv37sOqVQGlCcVOSyQp8mN+Kdl3CXRiP7LJMJ+L4pzmE4+o8eU0NbFa8r0PbMcqBQ4jpGvwKAHbApu77t9emNzo3vD2HLRmTcr6Ptauh4sNYvNLzTRlUfotmcWgi8eXZIeNsu+08P505nZ+Dj7GwvdjkFxmSd28WiGSzHmdY6A9T9HH0tfEvuvFJAtF0MSPzNzL05oVApEXHc2MdhLIJm7KTIFTEXzuMZBogmKqMjp/LGY7OznxQmbLhoDrwx/+MADg0ksvFds/+tGP4s1vfjMA4N/8m3+Dpmlw7bXXYm1tDVdeeSV+//d/P7Vt2xaf/exncd1112HXrl044YQT8KY3vQkf/OAHD+1KlCSHtvLT8ImagIcmXQ1G3ESlwQ0AiymKbEJqnzS5NJaDF37eAyGz8sgh36GJZcSHFSBd91iqJA5aPDO89mFNNjg5c/CqTWaR+BDNOScMFWjjy14uGvTZrXHPEl57i2QZmUyRzEsb8F/VJuNCQxqJK+LaFgetZtDyaFyluY0m7gxo9lg0e5EWTTlrRhqnQcyQdPVhfBXtU0z6yueVCCiB1c9KrD/FUkTpc5u4XB+MMxmJWg4MtegS0AdsYabDGC8Y23s0Q38DO7RiSk73I2RNq3Uey8x3ZQlpj9GE6JmmNk3vpma3LuTg5JDiuI6UzMP/J82jS5ODjDea1Fhyrj550qRM2xqjfepnxjX0AJYHDWwbMxvue2B7cU7+fV1NHEQHnwz2dg5cgAw0vn3ffOQNHgTciO22WbQAGDXmOL64UOgY+C4P1GZ+P2vSMdAiqcVy0ecxoaDkWYSCMW1E9xfHRwsLuaq3zHP6GN7GqmI8Bqq1wodE6KD+sxmSCBfjaZ44oGTWZplyitoRG9MeIy2SyMTZJy2JzsHHrT/z8wB5YcDLllCW95gIdykRKazx8bgyTrbgoLjspsmX1cIP8V+qxAmC2MZDG4hhyLX/V591b/UeHUtyxOK4NoNY+e9SrI1oN1tIo5jV1gItbSDwyMzD/Q/uSFneu1ACgSUz61ANLx43C9bSe1hO+7LNHIPibRXI6/P1KWN7/n1mmWnHxif6x3gNLtHnYWR5ce3qUON1allAzPOCn9fWtDlobVR42Q4ASROx0hdpv1aK5aNFSmgSOcY6B0mROxIwNZ3WyfyJ9B0O+JUfvjm1+/3vXZrGQTKBJvdkAKXku1xDitfgM72dmXmXyXyIgN45AbzcErKQwyNHLXDVHhQOWlwzodU/7dOSSAMs1ovMiz3yxB5Zg1KqGolDSvZLJUo8pDmQj9lMp4NcgLEfAodJU0pgE2SAsPZr6cmOTHnxkGBO7TVti+5BNunEgE0aS2IPplyE+bcYBluwCtO1pXOW7DLaroOneTstsZ6TzIxh0vKZVqCzaPDnzAogjtdY3iVBVhg0Bx08zCdqonzzzBmpnZi4WUyXY89RGEyQKvNGQ/vSuMpfWxcY1WmiLC2Xm2JzrB/l/2sg4iSZWY4qEHOw6pmGxsklXBOK5tWSrEKypemwHpbQDE9uek6YL5E0LdKsJm4qgKsLSxKgaIwYgo4HgkcK4mf3xweHi8+6rxjXQg5Ojlrg4qCktwPl6j2t/iuki5o2QJOuD0GAlu5DMxd5X5Tsl85R9U2F0mfHr4WTKPT5W24qZfsSfZz8ZaDxhQSEjdKK+PWIc9TMpsSECyWdPp4L0JlDtIkSzC9FjEIbZIZ7wK+f+d3GRNeb2gj12wIeIIOWleh2TAMqTI2Wz6l6bL1iNA90zuAwXAMDEEHrnsP8qveNkXm4r4qPg/optC++MEO+lwRaxPCbIGbZsPxIfWiwxa0P5kOmDbnSv0egtazIFJQIOYI/aXeZsdgN/rwJPLwrKxIs5PDJUQtcWg62ptOYCK3NcTJGgA/ZB1Zj2/UBCkTGYrnmMw2KvpHBhPdbS9Hkg2Tjpe0YNCfVT0FTx0iQdijHwEUTWzjYWeO2ZCzDhxiLpbkqk9MsSb7EsfHMZfTdmFhkDNO/VUz+UbMaL53iQZWCyyzxXpQ/yeOxNVSSmkmQijYWKatYRhAtXKPVQs9tixx4/Km9r8Kqn2A9tGhd1PQ9BuB2fJHWywS8LNtFbhOfTr2goGvsnUcDh5Yt9qio5QLADr8clcB11/1D/S2xqpPmNqtulKV18clakyYItGJdK9ZXenGDViQKggff3w2Mu4kwEQ2gGFwxZguMucmN/9FmUE68SGdzPtr9QwlqfYhmUsuMR/0nkDG0TW4O5JpW62SFZ0qITOfhfkXpkyz9WC2bLLTZaszcSn6I2C6DV5l+SAYtx2Boljj2MPrLtOiA4OxHss9J18FNnDWxypnkc0lK+Ib7YbebNNrMEGyEmdICrVw0kpMyokZJbcl86pn2Rb/zlqaLlYtDBKRsTs8myNZ5rGMpaWDLjBnInx0yEcZjerFwadCgDQHrDpjAp3e4RUixfgs5fHJUAhdle29EDBVNrDSB0QMoiQk9hmSvA3hx01jN76Lz9HGfUTp+ACXejjMdecbzhpkEyQwJ5MlaMySpv5pY2gsfW+w7mwQnLhT7dJhA4yKRhJ9jVhzXPNoQnYf6bp0mxYQE5By8iFmJ4To4QOngV64t6WBYDV5WgcmaWKbCmq+L5ym0SB3CvwWHmrZpZWwQ4xVjjxN4UWSS/SyN0MBdMt21zEy5PjJt8PtG/rNc/2oaz8tNlUqLI5DS/lcNwMTWAxAz/yNnqUjXAo8uLMUsKC7nrZy4Ph3bhRarIWfQmDDiRdIW3RQYwFYsIJAXPfHe5c+8sOoshutCNi5HJXDpqr/3PhBX8a2L5qxaVWAutPLnk7GdykdOijpgl/vUhNNWKUbcv8TNC1Q1mOcL1BnZtfD0SZ6dIx7Px16XGlGF0+BrYNUrQNuIcI20Ud/FOFxIoQ5xXBnUgRiDNo/fZZ4A2TQ2ZH/TmJQmpghgHCyqwdNC2yYQkCSd2B/EWGb5vahWFWkrHGTH4s0IdET7iuZl0drjwkPdD8evTfrZSCvj23nqrsnw9JE5L9+PJmlJvBI1XRu9C5aVIoKe9EGKZLxooLN7WEKMyV69vwtT4eGXoxK4tMwDVFrO3fkw9t6vY6rkA6iJEg2CKM0xFjDrk18sbu94gtHKcz6rCrAYl/CfcRIEAxMFCpZmZpItUCehUH90nAX8Oj7s3ge2p9+I7jvv52A8RRN4dC7G8/D7pRlxcZsEL0Ev1xrUjKSy8/i1hDaoYrh0HkNg/PceM/NpyYHEjQleAETmB04L52PjBI8kw722juHXR9v0fU2aFtNqfGgykDFzYSJFMKDSGUjiwi733RO5RY071mhjwAdfxNBN5CGF9heBavgdQhwNyQK0nh05JoDrYEXStKNWVE1tM2hCTWrrhLkgtWPgwzWwSXoZQzomBk964fPSTDydeJT66gNjVSbWGLBlGGMLae4jAgZQApgFTi0kgEF95sL9aqKPyjtN7RP4jbz82txnsS6ln2ZgHDKmIY87mifg2KKy0+fURpMShBY2rOqN6+IJc9N41fXWND4rc7tINMzAKZETdO5LJ7U3zuAT7Z1xjS6kgF2gBCtBBIIXfev8f63zACNIcLBK+9V9EQsUeGxxHqthksax7PrU57Lro4kwEO0i9rceWiC0aUxbmi5R4CmQOaWXCkE8M7Sda1wtggh0XsjhkQVwjcjLdmZN7dv7Yhb8at674aUR1X2TWYaRHITvqjQzxj4Ua2wwF/bJbFiCaiQK0OpdgVnIWTViW2Di3MACDKYpURNRWgZ+s0SPz2IQUr810cHJxf6Uq5EmycrvMsOsOg9oxX4qqZUqILLMCiCaVY3ZeGqkjmXXS/8XXOHPklTyMqu75cMTxzF2HWlhqb2Z25PHAfrC5MfbtejNmKpoUlMASSnLlFmxZdegtdlsjkcyIdYk+848fuacb6ftn7z7ouHcDTgpiYgYsa5WBK1l9MX9bdi5xQIq+cdi+//uJX8zOr6FbEwWwHUQUqMDW2y8seNpEtE5AYlZyF+EHMNSgpYkg5QaGI956kMkr7Rw8BVgqBmeuAnQEp2rsHY/rH7IPMhFXyP1ZdefkixDi3E4i76+USEtRoNXqVn6AoCAOujNPm+NAVhO7JYWyEHYCl5umY9KS49GUOu15qNLfZAfSiYF7hOZQoxX+8JAC7qsZWm/nz7OWiRorU2fk1h//LqJ6GGBVibxKE0+3SOX7vEivdOzIwvg2oDQA0t+Eh43JFhQNEkqU2GDgFWWH48YbiRELtjCVusc1LrQYHUkaWsibYR8Pj62eA7KOE+MNm7KG/aHUrOkAGvaXCVvDOAlAp7VOONfvmIu2ZlxbCXIyEq/kpU5Npnw/i2HOfeJaOGTu9YiuQmuxlYkIfMgUbxb50XuPCIu6O+6aq++T4VJkMbtbICzNUeuvUutQgh1z6jsViYLql9V60f7jbqwJKjveuwJdMh3xvxg/B4WzwAbRxeWCg2wGYzktJ3Anoo/xne0NN2O+R0nQ1iJrywwFnLosgCuDQonUGQThgSwWtYOOj7Hhfg0kW5pejV5KN+G4S+jc9N+msSXnS8m2NyeaLzzmf3SuV1s70PAxI0ESof6PrO9QVtPFGhDu9KxbPq+zNKofHBYhwQCPpHXSshzoQzjJPPQ/LVPS7LWmN/G6Ioz4/jnFHSrsjtQn1xDyYzAUOQKJFq6Tv2liyvG/odFlfKTcW2FwJUz/agPfQ8Kcgi/bmST3Zjo35zuS/Rz8XvToXEeN91zHtZDO5y7LYBukhLrlgUmx4QWsikF24KY8azJArgOUjho1cQKGG7Yio2i6zvXiGO4dGp1XAMkzTiUJA4744VX20d9TmxSEz6vyjHzMOKo341st8Tyb1mZ4jNF2QYnnoNQl90YE10X6lAmLCszg96XvhvaVWEWI0Yd0+ZaAOusn2Q2rIAWIDWZpPkw7YuXWbHGRUCiiSlW7JtgDRr+YUtIW+PnIhCyM4uUhSVpPLRAGHt29e/Mi65OMIShuOwbv/DMB6p9LWTjsgCuDQj3o1gZLCzzIJc+5CBZflyrdB8OVtwvQ3WkiBlH5jgNUvbYZTC1ZAPKa2oArPP8h05OHjGP4fi5etZei21Gi+34C26VYeGsSz1+zdTkvsjVocRFbJ+py7Ux5rFmUKJzaODT9ar4BE+mwZqIiTuwiZ4xAOfJbUh98dgmAgqyAiyjT+NZTgsPMpbF8+l8jRzs26EWVdb++uF5kFpJAaIDkCxzbQ1NMvv5kOOmSMMi3xIJtSFA4fd8EnoGyvFcJzRrrPYWRD/UTmc05GBHDMHUpzLFUp03ft0pxtLFxUETGJNzIYdVFsA1p9CEbQGWXIkSO6s+OSf24EDCoBWankD1yxGPadE7j+XgC1JHHNMsLahM9aRrk0ktzKXYtAZDWirFZtTTqYf0XwkTqGMUfiO+7WDEAkIdJqBZgz3ihLaMnIbI6utQy55w7SRrG1Isnw4ggSplnWDbLQYefTbHASTAWU/a0nAua4HhYubCnoFWGUBs3Hvmw+PaT4yjagpTZfQvLYnvebxlzFcPJ3xkWntaHmpyRYDNwcXkCyv8m8yvNkGfgEprkXE780uPPLopzVmlzMxCDk0WwDWH3LZvJwAVjzWHX4OO0WYz4Y8JTXVVRuauonhkaNC7kHIk9qAYspBeGA5OOoiXf7amZRmnNK7N6fPMkkN9ka0ck9VzFQQMexIkGcuiMbZvVkaNMeLHwYjJvmOmrXnKq8QyOsyEOKLNaUC0SCO6PZncaqAs2oOVCHEcLBhbkDExKaN7AiWDwAKUtHueY3JMy+bntgg+8R4oUyGzfujcnAs5/LKpgWvvAy/Dq87//nNyLosCm+KrNAMPOZ+gMFs5+SLomkAkPLsDmaZ6SPp61GTatBJcQS9MlqSqUfonS6xph1aJ6RoQUu7HOGbmB2HH8XNqoJTJeqUGpjNof3vfi2cC0xgRgzMG+QSVf796uqJYn8qLbUIcnaMpjuP1vHR8XR63HXQM5AmZYoJIMyPg0dTu1H8FFDq0IrZJA4jnC6aQr437iqzzie2VeVkQMiwKumGmIxNnAy8CfWsB116YDXt4V9ZTK8MDfDLnpm0VQgyNU8ZtyQQBgpyl3j2yNNTK7yzk0GRTA9dzvaKxzSLKDBZknrKnwxKWkUuB076UcDdkpiEnE5Bva9UvJTNXLpviC9CI2lfsm9iKfEz0GRhYhezW8RWiAC0XQUungKJXuYPDBEORyKFYJGcV9nBlTJqziRdygi+1Km1S5IAk/F4DfloxW/kcdQ2gFiMl61N5s20q/47BpGto05wwATDGoBkP5YpJtca+k2PN181p3jKrfNSROUkCwKj5S48hm1mlH4+XBMk+IS9o+1vQpeN4XSuemSKOa5CwBF4Di5sTKW6KFoBjKbBSouTQpHuQ4sqY/2wyFIXMx6nQl4DqgjC1Nxa1Czk8srmBKzjcvu+MgqAwGZyjZxxEjsLR881YPfEJWdO8Kb7L2k/CyQQT59EGh3WHQbMiiq0yS9EkyjS6Lk1OOWYpOY8RhgS0NgjzrB2SHVZS3bvB90HNKM7Lh3zN/Hr1vRvzb5nkFq3ZDhrvPFR4GXSrs8dL39fMiU+MydYG0n6D9s4JEzU6OxEgNMhZz1+pFdntq6ZMpWXOU+Ayv2958rd8R2JUQnshMnooTHNWPS/KKcjBTQsdV/sNi8rRTNuS4BkEUMW+8wKJF1+Nx5aLw3i+eljMQg5NNjVwccmTAH0/fEImPcvcV7RlL6+mYc+yrdM+0lBoZSf1K2n3J3PhugOWQzbfkEbHI/e/cu85mLgeXYgl24nhSMBPWlsPl2pzxf35r2eX3oUGPLGwBrW0mq/4tXRZErpH+n5Y1HbdhrdrVPwX3XeLtq4XAo3wn0gtmU+uOgB4THQBRJowS1KL7X8hAOBmxKI/Nq5Z4BMnZzdopm06VuclrGusEiw5II8Ve4x9KN+RYjFaNHwSrmXNyupPvysxKfUYdPwYB9AmgVgmtIyZr8UCjcz/THQKt4Ucumxq4PIYWGlMbaeqwofzUdEak3bK8vFwaVyIYDLIZDBnaF9Ij5hMl8d40fYIMNkkQ8cnLYqtqtfRJNPjaih/2mh63DIkCPU4wXXDmOL9o5LjE2obWnRB1g1bDS0miAGnJzYdnvQTrIUWxzdTgGlxTw/nJwCWrDY2OSdtTRJX+H2g9nwhwCcWgPm2gjxW98UBrAFSolQMTnzL7JazZjSDT8ul7/F3lhoLlcXI1PQ+BbzyMcZry8emtEKBNBgq7KknWcP8WNyzktaeyBuhidkkHIR/LgnToGtpk6q5GzmgMpCpaVRaysVCBmOS9MwnH530qrYIWEcLqkyc+4HoQ4+efv98n7IWZYXB1Mha3OLR6Xu7kMMimxq4AMgJ0CEx63rkOlzAwZU2ISkIFUBJtFCag1U8Tr+MfJUv2gsyhJrImS1f03QJ1LrQokeDS19ylzj/T71kLwDgpnvOw6Vn53237dtZrRtEIOmDTALshxgVIpIc8EsF+NJYJs5Xta7WBbx850PpO8VucfOLlXdQ3xu+COAgyffxben+FiPS196I34lfW3EtjL22XOnT0tb5BM//pkWOMs9tRBoxuWdNhbPySNNLUiGoVMfJxWD3zSq7opMD8+21SZ+/QwVpikBG3TeufaWYNmUmjlBdautj77ol3Ew/b723hcwvmxq4evUAtciVextIUgHV1uIZ3w/2fJavRpusrLazKqFqLURsY8JfSAKteO0BF5913/wXNEgXGnj2glqajB+2c+BuBi3Uww0answD5xOQNsKnMyYiNo4uPfDJSBJYtOZa63+MwGAJB61MYBgXQf5w87HXLK0kyUAi4FLTVniWe+pXAIdxuQReXHviJActo4xLPmajTfHOHMRkri0M1th0Yc2Zz9vQrgtLog8PiHPRsyUrk9uLzdhHBq0eDn9+77lp4biQQ5dNDVxUWpx8Mo2wLxvkg4NU2WnC5hMlxXGYq3kHkfSVm/ZIeI0eHxqss6GNaQjWBLxOmQzmXJHz5KfUJ42RTFmUpaMLLajGEAKwZfCRJfahyxPv00MJ9MlQ64hW0t3gh2sYGHH24ixJlGJH98sJsNL3jN9r7lQXEjQbrTS/9eq7ltrkHX+LHutOag1WyiYOLmTiyiZLG3Cs81JsU4OSrk9/LdOmtU1rSjk4ezwNEgBMXDcKSplAIZ9xM1EwM+fx/fL3HUyr3EQ6vFs9XLqfol917fr60zMVWrMwbByPslCo28Lfp40U+1zIfLKpgYs/HDEb8wAUBBpu/sl8TLRJqkVIFYt1YUhqn1ZbbL/lZ+FC+wksqK3ez/tJpgjnR7Wtu+7fDsp8MXEyN/ePn7VPfP/KveekSXcdQ2G9YdLqlEmGB0cXKXoQySPRhBs1jglixo9l59OxlM+NZwLRQdd03WOLD20uihOiExqklrFJlir5NpCr+LExSAKIN7Umaqcns8Z5LHP/F4FXMa7SLMbHRyAW99uTpgzuHQ+spvbzECNmElUMZmdZgTo/19wsab3LWtPkmmMDsIKhMmOJRd6ygDr2aWv0s02FTTY1u/hecf+oDw3+h7PvrPaxkLpsauDiJjpi/DU8jdDg9+Jy+74z8KNnPjhX/1+/76yZkyWJBq1ZYk2yJPNkpNZgM8/4XsZ8SWPCi97ddM958YOLLztnjAltwnC6i+zfaRKH0Lw444r7wbQ2putt6d/Fuk8cvLiIxYbL2RS0CNNfRSwfiDy+1Jo0O1Efq9mPdC1j5+fj1WSGWTILxEbLnBwmsRZqAGxNrNLOGmPt/bX8ata1a03eEhHMzbZxUyEGosisLCsLmU82N3AF5oNwkSHGGWtdaLAyrK2S78QFfO/+HQAgSAGWkAbz9fvOEpMDaVpkqgSkOZE7ZKP5J46zA1Jgo86OYU3C/JwbBSots661JmTq65lpkyay1ZA1ty2uE5rFemjRIQbhrocWvWuwpekA10XSRcj3BoCg7e+9f/uQT6/ma4n3Tk/8lrmQmzvTxBdyP8vBJ61Y98F/Qy7aVMVHyc1c8VzRDGmBEwcmTXhIRBySGUSF2vdZx5TAKTOLaACsESlmiQXQlt+rh8uB3M5Xf4O0zdDWOBGF2rVgFakVQGkQ4fFg3KQPIPm+rOOKcajr78OkWGjQwrCHw/949veKa1yILZsauLiPqQstdEVf8kW1kAGFG13r8JdGkxfSOBSDSK8gZaZpm+2mfWEtQlEJ+bmWTgBWSGYPIGtUDTw6LKF13QAUS+jQpuKAQORqrfYTbHEdtjQdKDsBzyqihZiFOi0UkLUmfmu4X6tlmq/OeF4kSmVARlDsg0uAphct8/pK9URG55xQVvURALDOUXsOyI+S29k+Hcp0QcKDgPmzK8YuhpHBeMx3ZElNI6b+OKi0QYK5aIvy3miA0/46S7uxzJOW1WAdErhijJzcpq8h92lrcCnGjl/fQgPbkGxy4GrUdwcw/xCQsytQXkE6op1v7gEQzXJ/dd+ZwhzYqFU6t7XTRMkBqGZOEmNXx9DfI1nLZ55V4Of+5nz0cEkDo5fz9ed+c/S4v7rvTDPDPTCwQofPXt0bOkdNuCbL2/N7KggbgglXAtNyorlTzSXFKFOifTc1M/BGpLbKJ9AS29PQZp+LL+g8AIv+n8+VqBLp+0ZMkjXTrqWd8P25XlgZTLwRsX5be4Fg57PciJigxRcGQ8wl//4nf3MBfvqc7xzSeY8V2dTAlZl+0mdA2SQAoHdxotHMsrF6UmOi/SZ5pShX5SQctGraGrXjx1C72sT+fJKrzrnjoI47uemGyXLjk5HQmAap+bIAGSBa+yz6VpoYgV07aGUdbNMdN2HGY0smof4+D+FEg6F28tNiIeY+jM8/N2LqkvXRGlH2P+Z70VqYzitYbb8ByaCRafnc7Mv7HjsvB/ox0M/n1amnSvAaA2mpETZiIct9vVR/LbavmxYXMi6bGrjIfwFHD1pJcsirSTIdHZy5kKcR0mBFkh/+DDydMGGMEQByG56KactRXMtHp5Aq9g8A0iKM1u7iGjCP8+L+x2wmVoUxmSlSZ/6mUjE8tiwR54fwi2JMw1eeMQWwzcJ6exx3I55beiZa5j/NfcYJcp2ZrlYHP0qHOEnW8huSVcAyu9E49Li4zBNUXDsn7a9pXdykKe6TMSaL1MHPMytLR+HTdHYIgQ4FGNf4bdACkPy+cT9SkuWFbEw2NXCRUIkDEouFBCDlETsYn9GPnXk/AOCr973E3K8fap1Ud+wY01zhYgnwQ32kn3roLLTO4bjt9x5iT4dfxrKZcE2MFgyaWZi1EAla+jM3DaaaSaTpMl8ZBygyLbcwzMth+B/TynimDwBl/BV7FBIpxLwWK4PEOFO1SMwLCVrz+NJ0lgp9zFj4RjlemaWjdvzhEIteX9Oy5tFquA9X91XrZ6Yvi2/XWp/6/sd7X4nXnfvXM8d5rMtRAVxAfHhSrjfYLxXlNYzPXWQX8pX8PL4knbTWSn/ThVZQyi359r4Xi8wQXGjCPPcQsnyQvGBHZEauPXwOVraPj+n5InTd9PsAEmBqmldtItVak9W2FWZFJH9o6wzt3AFUmp0f16qinwJo2YKpQYB3Dg2c0MgtP2iNNRm3qTgxIztHddKtrPLHzWGl9jErDVY1tEOBWj+kKbOKR/Lv1v0p+kYJODVG4UZEa7u8FhsHSqHFoamOsSZ/vPeVALAAsBHZ1MDF/Uk6vUxtAiDw6uGS+XBWkOqYWCu6eV6OCTM16XRIfJV/uGTiDCbU81wmGK9ppEkWZi7Eyr3U07PWqvTdatXnHkDjYj/ERKQ4N49cwJO0N67xAcgAkya88SKUetKPfTRo0QPEUtQ5B9Ox0vSo2as86fB4xvW6WD6yGsiQkEbGmasUKFzzMVnPQvKJ6eMHMLSk5u+KWmsGGvKZd8hZ9JM4GWgvTMCDiVCzCLnptqaVlel/F6JlUwMXoGnmMruzyK0HBV5AAq1+ADNiDraYL26KtKqv3HsOAGwoFxmPq/r2vhenjOskh0Pb4tJsu2t2o+eZtMkUpxiAI1pXrR8i4xBg6VWwR85vOYtxGgs8EhCU52rZmXpIVqtFw69RvWcxUel5z/4fWeW4On4DIGuLA6ExGUzKGknCsnyM+fZ0fxvVjHjS4AheMtBXmw5NABwBMwDFvR0DP3OMgVcMgOiTh42QfGrvq2Yyc49V2fTABczjKC41Kg5a2ukNRBCbN+h3lllwlpCJ8q77tx9SEuCjTbgP7K77Y6Z/mvy0vwuA8DHNk+6LwGRMatNn61za1zEQI21s4oAuyMlakDvmEJ6RwdakFM1+hpPfimkaM/Vp8PFMU9jIOawxz0NukGOwfWyW+e5g6OwcbDZCljjc9PyFzCebGrgi+ESVvB0Sx/JJgmtbBF60n+rkWC/ZocaLLOTZEdJAll1O+lvUQFOsQRKudQFIvk2u1ZDWheEvaVMi2S4DLABo4aIGBqBHQBdCOi73FTBRPrTIKAvMZDj0xytTKx+NZQKPYyAzpCw3z7Ub3iaOofS7RBZnCTwFcWTGxK4Dvi2R2pd9vni9dWDN7epLAQ18OQWZNOtpoXbShF8yl8V4rLlkuKe0bz20yVy4mGMOXjY1cHHGFJlLrAzSgMpPZzCpxMtzmBnof37vuejCElbDBA18Ne5poW3Vhe7N7fvOwHLlxc/Eg+xL0lMJ13h6uFRRQEz4YQAr1j2BU9Gfi7XKeLs+xPI6GixJPNj5nPRvWeERhXAArjznuh31Z32eZwLl93sehuFGmLs1DSn77eq+rXqG/jJIukczUyMEJGiRNKhrvjWhmC2gNDNyGdPwPrX3VQCwMBkq2dTANUusicBuJzMPUIkRyiOmS0/MYxr8+n1npTEALTNHLhyvhyKUIPmu+7enDPNa6/KVVTH5rnjsGGeaRv9n/oX45z6EBF5aCLz80E5qaHS8OgYZwPrB7Elxa+lYdQ3JPMqHEVCAmwWKeaw2EI7S7RmxA5iv7dj5rKwjBF5k8qwx+DRQlUzERhSMpG1yTBm8LNAQgIOQQCsGeNe1yVpM1kK7Ovyy6YFLByE2KCnE86ySeLBgw4JH2+FF4qtMosG3qBduvPis+/D1+85K1YhrZp6FHJy8bOfDuPeB7VhnBAdK70X5KTmdPWlZ7CfQrERq5xF9VFw6DOCF0lzoQwSfPgSshnyuidDY8vm5OTJqZiGZPQXIaC2G/HZscpw4L4Knu2KSziZzbnXgksoBJfCsUNjZthpFv7ZYlDFiw30nH16gKsbDeTnFfxBepiX26wvA2ogQBV9cl0hqzJmmpclWX7MlnGhhzUfpfinA48C5EFs2NXA1LhSpbDIrSVJauT/EImOkFzZIU0E0J3q0aaXL7PwzGFw6oHMLOpHlYCGHLtnf1YAn4u2JtjdoU0Cp9QhaeMgMwEhhzxrXloEpSIAVNSX+28bn6wACY0JGckZqg4FxqOe4gTWpQyJGCRDJX5WDqVO8G0JOEzYAAs+mb/ZnJCzW/jFuttTpzui8DfPZNS5UJ/UWQcSvta5PPjc/UPx5CRB5bOnzqvnItCXFNHHOeIet4p/ztO9DkzQ1SkQ9jGL4fwQnDl7y+rzJNFxIlE0NXK0w5swW/jLW7NxtkQtuMO8FAjdqGVdsRIXv4Qo6fIxRibeYvzSLZJqHR4h1eNf929GrCYWypCTwGoQycnjI6tkk1sQU44L0toAW2b/Vg8WLDYxC6SORmh1YW4RsNsz7ykl/I6DGj9momco6Dy3+Zsl40c5MkLIsIRoIUfF9Sf9c1sBkX81MX1ZqyzLv0GcL0PphsWo+Iyp7D9eiGswAx8rvSn008PjU3lel/v7hS2+d67qOZtnUwDVLtAOakzJkm5yBIJobIyBq8FoPeaXNAxIJlG6+92XDtvi9CxM8HZYxQY9lNq4ebuF0PYzSIGpeHdOUAYgsKbSd55tMEyhVEGBkjYaBSDf0Q6bCCWMRkl8LyFrZeqA6YDRxhUE7kmxFkh52ALTlF4ttcnopYigCSCbDPpSApxmU3CKR+h8hdvBjrOP5saSRWbF2Y1n1yepBAcnp3YQ0QybXgDb1KZC2wLFKBClyMZZg4hlRhMaRM9fb2pzul2th84jVxyfvvuiYB6+jArjkg5izY7cqkWiVRYh6xoBkimGaVzxLXAn1QAFKfnAyN87jBKybfcJNF8k1D7NscT260KAbzIbRRJbJDzThrKoJbx1Nyi3ZuSZqUXoiCoH5qdgkjWwSJK2kDw06NMK3FLU7j4kiYAAZrHSMl/aLULsuMH8XyNyWTYY6wJ4+U/Jmvp3aatMemf0sszoXGtsYIJHo4qm8D34OkSQXMh+hDiTWyW+5WNstC0jNHEh+b2ozcdOqz8/y9ZFwUyEHIgGyZlhOPkaavBfzxqYHrtK+nVdFjTIJ1mzfWtKDwphFlmTwUg/g4HCeuGmRsJP3PTvaZSHzCM8y8u19L0bjBh/K4NfkE6ZO0kvPQSRJTNN2WvyIVE1s8kh90OQenCBGdPy5C44F70Ytgvu6ak8iZx7SuVonn8cOMlXUvBlFLNJGDaA2QnSystVQH9o3Nh/jt15wM2t7GsyyFaUWcE3tZjIkjVyPs+4BBzySNFfMkXi3Jsnk6DJNHjg2rTabGro5aPnQpH+U8iUy+uJqjiqZWo5cYTZKE5VH5BjOyMpB5qnQ5JWUMEVm9lNKSTX03TqfEmou5PDIhWc+kH7DdTRYR5OCzfVv7NX29UFL8sFhdXh2OmTSRzQLxvP0AYnAAUTAIG2PzteFNv2jcXRosBqa6AOrzH8N6i8mZyUSYK5bk6ExIett8+TnJGLGobJi9XvGt+nzjF2DFl4Chvqpt5NMxLGxziO1xS+xk+lfTWYCYIWyr+ckDmLHimxqjStOMkbJbKOQHoCy1ERqz9qxFday67Ee2sQA4g8M/1w4ZgeThs4cLca+udcMz2vZ4nqshjZNLB3kKrmvrLq70KbfJZamjww9Yi32g0nOB7lSXw3tAFitKPTIF0ltiCZkhGgK6+AxgccyPCYua1f8qeAMRf4EEcGEmwUtpp9X/rzIvKwn8i2+O6k5WjKmtWhgmmVy1O3GyBscsLjmzLN79Oy7TMLN62yV2uRYkHKyqLDr5+OOZuLS95Y+M8KFqNMV5FSc5pPq4ubYpsxvauCqvRTVB94yGwa1KhoObRml3npAZhWAE8k89UsFDCbGdq6kqAvZmBxMBpKv3vcSrIZJMt+mbA3JlEh075wMWZMgeHtujm6cRxeWYnHHZCpGYhQ2RA4ypJZ9I+7TjEljPMgTu0eMR6Mx0b425Mm38L9V3qWq+dwMLh73C0lq+2xtx+qPv1vc/2XFf9Ff/V7PA160+NDvLYGWV4CYxkf+RUZx18eI+8EWw3qclhXok3dflD4fC8SNTQ1cwGybs8WEAsoHCLBfCP7QkNbFnaZcnW/ZZGCOwdAOF/L8EF5nDYhB5jTZAwBcH31mzpnaDmlYLfpkJtQmSWCwBjgf45ZCAzhgGX6YDEutiwuPBEoAa4Co1r60Btiyp9orDaK6wmexYPM8wxvxY9G+eTQtq33NqhHbSRYhz7QxK/0Tz+IhCRU8jVRlAcz7UUAzr6bUD8+HllkL6U/efRF6OLzhpd+Y6zybUTY9cM3zInFmENeSrLQu+higfAkJpDj41Ryv1aj7OXKmLeTIyTpaaX4Kg4kx5NRL9HsSUOnjAAz7lrDFdVVW2mpo0SBgeXgiMygxn5p4xrMvaJ0xGGkcWvsg0Ep1rxzQMnNnNq1HgorWhCxzosUQpG3r3ETKjp04b2pkmtAhmby20H4NWnQd8twEMln7Sv4uR0HPdd9YP5iQKXlAj/wuc3r7PIBeZNOgZwv2GLQbYkzEXHOUJzrY9MClGTxj8RSWk1OvXqz0K3wNnNqzZ2x0RaWfxYVpcFPIpS/J9cs+9zfnw7sGE0SWKNHKiXUo/Up5AuNZUno4VnCS/E3DMchZJ0Q5Fkb+6BkrkoKtCTx5pYN1tIAxafHJ2Ycm5eOM15HN4utsMcf/asDR3zPlv54fNAV8G9v5eWdpWEAG8mqF5zmEgExXL06MUpfBjUCqQ5vnEhfnBp6AV5gbDZMe38YT8Hq28B1zQ8ya67h84q6fxC+87C/nuRVHRH7uK7920MdueuAiEYBSiW6XK5LhoZ3DyalXVZx5SH3V+tCkDi0L0+HzXyibPyVd7ofyIWZNsFEtYahYjLxiz1qPG3xeQ4CrC+DZM2rpyogUws/N/au1EiNlafv8/lg08FkMOK0x5X5tdqNplh/AYB66ea1A5OEQHi/Gwa2l81YsN3QsIOeMWczk1H4GeWxezYuP7d/d9Wr0weHNP7xnrmNr8r/99bWxbzR4pp9gGlp0vsVKE0NIYtxqwO+86g+rffyjr/0yfGgwHca3Zamrtp0lmx64NGiQ83SWZmPlFNNC/SbHKps86Iz0ko9VVqVVGX/wurC0AK1NJv/D2XfipnvOgw9d9i0pbUsyyHqhdZGmw/0jfWixCoo97EFxZ7oIZTxeghdpCNm0JuMT+VgalpGC9nHwor48GvTODyxIVTYIpSZG+zS7jqSrmKzWjUffOhcd71F5tyqhADYhpF5gkseATVI8X14UJKq+m6IJTVHzbUzT0vOIVTZlHpkn3qvW5mP/3670eRaIvfdbrxffW+ex0nCTb48utFjzS8xfGLDSTPG//fW1w3Po8Ey/jDWfIea41mPiPFaaCFjPTA8+knVTA9c0LJmMHMD+Aef94TkQxgmhLV4ED4oZkz4sbpPmNnC9WiJW4SIKfnMJT+pM+Q5jrNZS2i/9OtMEKLSvEQuYHF/YhhCBbWD5rUJqMRoQss+G2njxPnRhCRNMTV8V/05jN0kR3GzufDHZWvFdXCNK/dA10vvCNCbuI+Siqfu5P0mOMH1DioyS70HWpLwaAw9jAQYwTyEVJYXdOmcyrZrgJLeNuS6quRJHxMrIEU2dTcGAHZOYJaRHTFXmCx8inaPzS1gLS+h8jFWMOR6BCXp0voVvunTsxPWxKoAL2QrQTItzzyubGrhIStu31KL0ioe2WZpWbTVU+06gVvjaZpg7FrI5hRIp37ZvJwA5QY4x24RVQJnpkrY+sNhM4s4cZJ6+8uzaY7JBzPIZcd8YH4/er6+Hj0XT0jGAiWyrr89XtisQVOPN11jXsGrjtcZOcVnrYZJ+R1rA2AzI8TlkHgDiwJUyyc8IwaldEwChIf67u14NAPjFl301bfvX3/0fseYn6EKLiSOgkWZb/n0CYNL2aL3HKibJGUuZZiZNjx4NmpCJO61axE+aY1Tj4vZ+bQe2VkZ6m9Vm1Edh2LWjbVoyDFsDIBea1dEldmmPMhyCB6FTLNfETWPOQgx1vpBLh5D5kI4lWWbzY7YuSO1Bg8iEpbDSdHOuCUoyhQ0G+ngNYnwlTmDOgcOirFv3ydJ8SIprdPY95yLMsuzd5IxC6zrJ8kKaaz/4EtdDi2XXo3cDkAwugFoSg9S3suTMS5GndjO1vYqrYqw0zL+769VRcw8NtrgW3uUK0RPXJ/Di92jipiLjCrcgkB+2dR4tgN7lIq+UzYjKtbTOY2mkKOcs2dzANajymsXXY7Yj02Iv1R4untYJ0A9Hm0w7+bich1An2KRzp6MXLMNNKcSi4+amFiHR4fmCKvldB1kPLVbdJH1P1HaUVHoSbb7mMpPMoGK1YqB1BgCuPdQAQFs1RFvGkMwg3A9Ucn5/cjYZ8Z6pyXE9RGbkspOVjC2zZDnOsiKy3k9C51wPORGAtpz0IdLgKZVc6iMghR60IS4ENPlL/GZBxov2iKZjj5LtzJmNcBLkavOF9rVa167vQfLfDX2uNN1APhn8+MNvRvtzIdI8hskQPrGl6bDqJ4P5PJt9GxfNjTm0PydRDodgldrUwLUaJmiK0gZNqqkF2JRUyophaVAyfktOFpZZI5qBpBpdPrCkjXlQRvmNqP0Lef7Jj515P27btzOSbowMGpx8UwJCI57P/DyUfp70jBiMWCA/y2k7Ix2J7OLGsZbfqwtLAmSsOlfxmnqTPRj3l2bAUbMem9TpvsV75tW1lit0Op8OMrbEMrVGBmd5fyhuq0zFFE1eHGyjL3BpmAlyPzX/WxrPAFo0t5ga6QAkvN/a72nNT9y3xcvhWIA5T1ybJVGD79G5NhV0jecjbTGyb5PPbPgbjlVT4VpYQuOXhP22A9AGn4BEP8Q5O7v94+kVZ231kus6tSxz+HDOyqqZP4R+YDO+7ty/3uhlL+R5Ii1i0HBK36O0kjTpqSrdfNG07PKiSGvmogLuyDzCJ1deObc2ydVYtD0arIYJtqAzqdl6IcfNg7Q/r9izcJMmPz6NaVjEEVjE+yl9PGNiaVEaBDTrdyywl4OWJo0I3xbXFNnCg2tn+rcX42ag1TPLTOFL1HOUMb+IcwICoJIEL+a8Wf4/vdjIhUDlcY2LmWAaCqMQCsEQb8dAa9IQYegY1bgO+BW40KINKubEeTQsp1zaDlqdSooyF+vH52JpZn5YqWgavuVg7xm9dh7H8UKev0KTBk8NlPYNJqY4uefXrIEfTGA0QQ47XJ5iBGANUjNha/8DIDWsnGEjmt503BOPVyLmYz629DNJM5z0ZaUSMcaYuK+LxkX3azVMsk+JTap+MKc1yJqlJpFYSbYnrrcJIsx8py0ovB2NqQtLAnRjzbamYFf2aLDu2Zyi7plgog5mVW6hIZMqnY9rc7y9JakaBiVxBgpgmkVSiYHh5W9HCyJ+rWTy04uRFgFbmk7c137QvmqLhNo1zSMbmjlvvPFGXHzxxTjxxBOxdetWvO51r8Odd94p2lx66aVwzol/b3vb20Sbffv24ZprrsHxxx+PrVu34t3vfjem041TI9fCJDFh6F+Phr0ETv4j+zr7R07Dflhl9YGXo1gS/8qknPkH6tmPT0I/DBWV5Or3P3zprcdEMsyjWXgmiFSyBpFKfEKzhuXBtLU+OPW1VgEgPVtWEHsmOsh/HVoxAaftyqzFzZUctMZE0/W18PclEyqacpEGW+vp4aKPL0zSP34PxLkYgNN+ulbPyBI9A8OSqZhBq0Muc8TvHf99dIaddK3BYTUs4wl/HJ7sj8MBv4IDfgVP+xWs+glWw3L+5ydY9ROsh6X0ma4x+hcNk2WgEjXt8LvJNmbCX6Vl0XXm34aHa4z9WxrdT3MlAHEMPzef4xIjkZkFm2E7aVt6PtyobEjjuuWWW7B7925cfPHFmE6neO9734srrrgCd9xxB0444YTU7i1veQs++MEPpu/HH398vrF9j2uuuQbbtm3DV77yFTz88MP4pV/6JUwmE/z2b//2hgZP9bYw2OWBXJ5RJ6iMzD4HuBnqNPKPrwvt2XZ6xSJUtmr98h7NiS+PNfnxs/YBAL5+31lpMpy4Hm2IL+uqm6APy3mCcdNBMysn1hYNgF6yVZmUJu3SpAjnc1qpYZKn75p+T6t6Mu3Z/p+xVbrtW6Frkv6vzBjk4C39WTIOk1938gEO5BXO5M35RvMisRY2wNtb4TL6+vrgktUmgqfMX2mZQPn9iEzAQSsNcSJfD0vRL4rsj9RkFapAYM0t8j7XfHk2W5OEm3g5IGkpckCy54ZMgkWMF30PjfjcKmADgOkhaFwbAq7Pfe5z4vvHPvYxbN26Fbfeeite+9rXpu3HH388tm3bZvbxX/7Lf8Edd9yBL3zhCzj99NPxYz/2Y/jN3/xNvOc978Fv/MZvYHl5ee7xJFYXu4megVMfGvFwRdJGPl6r1JnirFaQgRWiE6aV4WFwTXwQQwz24+9erge0iOs6WiVR2ck0PZj9JuiBZh3wyzHvYLC9NROmmdWAC5AmwFW/jC4AW5r1qrM++VsV8UNnzaBM9lb6JF7PygJc3qf+zOubEWhRrr8DfiX1T5MmDwRO5xi0yNUhM/2WJmY11BpqBEFf5GlsSRN2fYzHCoOJ1Vis5r7a9Fv4wBeyMss/xXfpdzvGQlFqrwZ9QDp3C4+Opl12f7j4YS4h8NIuCwJTDjYUckNWJ+7T44BL9xkA1jyZaONvQxR4DjLUXlwjLXqGayANK5p1GzEntyweTP71mM7wXY7JITlZHn/8cQDAqaeeKrZ//OMfx2mnnYYLLrgAN9xwAw4cOJD27dmzBxdeeCFOP/30tO3KK6/EE088gdtvv908z9raGp544gnxjyQ7N51Qe7manNX9/G99UHUJgKhdF5YK8yP9Ww0TpvpnddmHvJKkh35dPUALOXrlx8/aNwRY8sSxDZaHnIY6+4BtKmLmsOFZ0v+0Sc1ipnIzIn3XmTGyabEVDDIyv5HZh2tl3AdSY+dpyamoFOFp8BPpfIDUP/VH77Iw7QVZqJFMfdSvR1PcA2rD7986e0/X1XtObdeHf3yRql0NJPx6rPyQlI2+ERrcUjEnraeYsWxmFGbIsJzNk36CJ/touny8PwEH/MpgeqX5ajAB+hyH1oUWB/oVPNVvwZP9Fqz5mPki/zaGX5+ZaDUI9nDpPDoQfywg/1DloMkZ3nu84x3vwGte8xpccMEFafsv/MIv4KyzzsKOHTvwrW99C+95z3tw55134lOf+hQAYP/+/QK0AKTv+/fvN89144034gMf+ECxvQ8Z3XPcQy5DAJDj3As2jM5szQGGvyjyXPHH5S+NNoekr3R4iClQgGOjuNuxLESP56vjxnlMME0TrsxpaVHNGX1egYMAqUqMvKTON+K8E9a/AD6DHVgTi5lH/ejjeeo1imXk5AsrZmyeRR4xLCdDjFcOFFZa53APiGWpqeeJbGHUM+sUWOX4LRl0PpYxRJrQ7IDk0sRIrgsZgF7zH3IflHVePwBizvfoEkj1aLDml3IqJmSw5c+RzpqizcA5uF6mxaPfl8rCWOM7FHLaQQPX7t278Z3vfAdf/vKXxfa3vvWt6fOFF16I7du347LLLsPevXtx7rnnHtS5brjhBrzrXe9K35944gns3Lkz/hChRUOgM+znqm4Dl16cmnj2QFMkudhPMSbsdk2aKeDkrU/fAoZSBbHc+8JKeOwIvbztUHgSbpgImnWs+mV4sEnSZbOgyJxhTIw6KDb9JTYrsq8mPs+RvUixTxYQavOgBhMrtkj3oU2TQDR5dsVk3wqCBZ8QJ24qGHW0f7lZK6jfPbkEmMYCABNn3RuPZtDQOqYVEAMvTurympIFRYEGt+xYYoELaVg6INpyTeh++HwkxqdMmmTmS+d0Ppn8kublJ8kCteYnor9J24v+KOfgBD2a0MAP/dG86OFEOiiuXc5a+IjrQAMcguZ1UMB1/fXX47Of/Sy+9KUv4cUvfvFo20suuQQAcPfdd+Pcc8/Ftm3b8Jd/KWvEfP/73weAql9sZWUFKysrxfY1PwHUDwGgYLF4BCC0xUuoV5C0QilKVSRgHGIVEGPFMNBu28G3xlOhjMWJLOTolC2ux+rgywCA3vmYqone0WYdbVhCE7xgAJLVgGeGsExs2qkeE6H6tDDjaYGIat/yVTM4yBgxQ5BgqX1eWqPSmhfPFsO1IZLemPizeU1m76DgWw5y2Xe2ZDIorcVpJwAzg9b6cPyY9YXPC5bQGDldXd6/IPziAPKzgQa6gKa1gCZwovuX2zoBgPx7C4+lxqftz/jlTERji5HWxez7bfDwjpGLHIu70ma/4XgdfB5JM2XAtWm9OjQPFYANAlcIAW9/+9vx6U9/GjfffDPOPvvsmcfcdtttAIDt27cDAHbt2oXf+q3fwiOPPIKtW7cCAD7/+c/jpJNOwvnnn7+hwUdti8dkufQwJBDxgB9+AJ2ZgMcblAUk6WV36a9gONFqyPWJ1dgHij6PJor/5Yf/YkPXs5CjR+QkH7Uf0sDMQNPKsXp7LRlujRkLTJPZTO/Xta+o/3l9WECpdWnQqgXjj63OZ01+s4hOQvMMjdC0tBkw9l/XeOYVrZ3qhLJpu+GXBJBMfiZwGSDFx00kNR7s2wWftq31S2KfD+0wP2YyG81dvWswQSyvQ4HEpLXPE05RMz0fKv1dy4aAa/fu3fjEJz6Bz3zmMzjxxBOTT+rkk0/Gcccdh7179+ITn/gEfvqnfxovfOEL8a1vfQvvfOc78drXvhaveMUrAABXXHEFzj//fPziL/4iPvShD2H//v143/veh927d5ta1Zis+yW0XjIDibnTuBBXEU0EnUY9LHoVQylJgAxaraPAUvKn0aqlTatoSkzZhpBMGA083vbyWzZ0LQvZ/NKnla8TE1+LgEmzhiZM0IYhlZLLQae1Cd3Mp6nMiNp3s6XplC/HpVx6y8ysk7JxOG+eh87Fk84K+r0xIQNkblpK7VM+vzknLaFhOY/O57inPrh4DTP6o+uP48hg1cILEkG6Fwa4jgUn18YLzJ7ck2kt5NI0PmSqPdeyOk/7s0Y19VkDo3uSg5WZ9saA6pk+W6VW2mk6f+OIbDIc4xyakP2rE9cnj74wgY/cFxlUbddiOxyyIeD68Ic/DAC49NJLxfaPfvSjePOb34zl5WV84QtfwL/9t/8WTz/9NHbu3Ilrr70W73vf+1Lbtm3x2c9+Ftdddx127dqFE044AW9605tE3Ne8su5bLA2rB57w1LsMQtx6ILIaFyvFMjUJbSfb79TzB7yJq5OBPiqOXZgIj3lZhkfvQuGraBHQocEW1w3+rw6rQ7mMdfWi06Son1U+Waz6iXR4M3OcZrdpUOTgZbXR38k8R9Rr0q5W/bLIDsHHkkgUM/wfdJ10HIDEzKWg7g6tuCa6Lz0cusFlwPML9pA+og5SqxnPRlG20WCmC1/S3hQ6EMqMFTwEh8YXizIObMABrKa+SUAF8IX2oF0lIkferq+hcTGjTwIrGjfzL3oXsDQQNKZDXa0m5GeuUQHpesGQvrN7QVp7bXEhMvQfpGzYVDgmO3fuxC23zNY0zjrrLPzJn/zJRk5tSudbTIcbLfxSIapD0b6tk/DGm7XUyJsWWTGDvb3pB3W7ZQ/WUnqIAKBHD+8cOle+TM2cq8uFHF2Skosavz9lTO9dThdELNhl9NEfBdv5XxArKit8noqI+82ACDoiCWwyZTlBHprll439Z3Na8v+GpTIgGhL4NPuNB/PWhAgbKTE2B9JBG+NkC20G1Oedh1xRI6jU/NY8MLcDM9sy4kzuu2Qwd37JBC0NTsV5FWiVmlecB1vhe89uFC/8XtE86IPD1EdCy6SJpAx4iAW5xR61tPYxwDtU2ZS5CglAn3mqF/4s7nQk0U7J6HgElppYnVg7SGOVTu4IDlj3wDR4dD63mzQeS0NFT7UOw1LjRazZQo5O+e7958HD4Ud3fg8A8NSTHj3y89TDDRq7w4qbpolm1csXfD0ETOHT9l4xYU0gVFpW+px8srn6dvzeiOe0BWVFd5iw592hZpXoEZTZMGpdAX3QcUsUfMtNV03arttR/ka6ltZ1TDOJK/ip8+hDGGK0HCg1m2YBBvYuO+fjnWAkj1xxWsbdWQlfNeGq88yFALC5I18XxfPlSVv6fOJYMTD8iDA2xZp36ALgg8c0tPAs/+qUAB7AGmlgIT5bHiVoaaBbbqagDO0+NOiH6sYA4JserQtYch7T4LE+nKdrPVaaaE1aQ8BK04l7QWL5SDuUPq0u/R5ZeXj6qfh3lkJkiQsHc9QRlgceeAA7d+480sNYyEIWspCFHKLcf//9M9npWjYlcHnvceedd+L888/H/fffj5NOOulID+l5JxTrtrg/tizuz7gs7s9sWdyjcZl1f0IIePLJJ7Fjxw40zcYo8pvSVNg0Dc444wwAwEknnbR4aEZkcX/GZXF/xmVxf2bL4h6Ny9j9Ofnkkw+qz0OPBFvIQhaykIUs5DmUBXAtZCELWchCNpVsWuBaWVnB+9///g0HLR8rsrg/47K4P+OyuD+zZXGPxuXZvD+bkpyxkIUsZCELOXZl02pcC1nIQhaykGNTFsC1kIUsZCEL2VSyAK6FLGQhC1nIppIFcC1kIQtZyEI2lWxK4Pq93/s9vOQlL8GWLVtwySWXFIUpjxX5jd/4DTjnxL+Xv/zlaf/q6ip2796NF77whXjBC16Aa6+9NhXtPFrlS1/6En7mZ34GO3bsgHMOf/zHfyz2hxDw67/+69i+fTuOO+44XH755bjrrrtEmx/84Ad44xvfiJNOOgmnnHIKfvmXfxlPPfXUc3gVz57Muj9vfvObi2fqqquuEm2O1vtz44034uKLL8aJJ56IrVu34nWvex3uvPNO0Waed2rfvn245pprcPzxx2Pr1q1497vfjem0zJ6/GWWee3TppZcWz9Db3vY20eZQ79GmA65//+//Pd71rnfh/e9/P/7qr/4Kr3zlK3HllVfikUceOdJDOyLyoz/6o3j44YfTvy9/+ctp3zvf+U78x//4H/HJT34St9xyCx566CG8/vWvP4Kjffbl6aefxitf+Ur83u/9nrn/Qx/6EH7nd34HH/nIR/C1r30NJ5xwAq688kqsrq6mNm984xtx++234/Of/3yq9P3Wt771ubqEZ1Vm3R8AuOqqq8Qz9Yd/+Idi/9F6f2655Rbs3r0bX/3qV/H5z38eXdfhiiuuwNNPP53azHqn+r7HNddcg/X1dXzlK1/BH/zBH+BjH/sYfv3Xf/1IXNJhl3nuEQC85S1vEc/Qhz70obTvsNyjsMnkJ3/yJ8Pu3bvT977vw44dO8KNN954BEd1ZOT9739/eOUrX2nue+yxx8JkMgmf/OQn07bvfve7AUDYs2fPczTCIysAwqc//en03Xsftm3bFv7lv/yXadtjjz0WVlZWwh/+4R+GEEK44447AoDw9a9/PbX50z/90+CcCw8++OBzNvbnQvT9CSGEN73pTeFnf/Znq8ccS/fnkUceCQDCLbfcEkKY7536kz/5k9A0Tdi/f39q8+EPfzicdNJJYW1t7bm9gOdA9D0KIYS/83f+Tvgn/+SfVI85HPdoU2lc6+vruPXWW3H55ZenbU3T4PLLL8eePXuO4MiOnNx1113YsWMHzjnnHLzxjW/Evn37AAC33noruq4T9+rlL385zjzzzGP2Xt1zzz3Yv3+/uCcnn3wyLrnkknRP9uzZg1NOOQU/8RM/kdpcfvnlaJoGX/va157zMR8Jufnmm7F161acd955uO666/Doo4+mfcfS/Xn88ccBAKeeeiqA+d6pPXv24MILL8Tpp5+e2lx55ZV44okncPvttz+Ho39uRN8jko9//OM47bTTcMEFF+CGG27AgQMH0r7DcY82VZLd//pf/yv6vhcXDACnn346vve97x2hUR05ueSSS/Cxj30M5513Hh5++GF84AMfwE/91E/hO9/5Dvbv34/l5WWccsop4pjTTz8d+/fvPzIDPsJC1209P7Rv//792Lp1q9i/tLSEU0899Zi4b1dddRVe//rX4+yzz8bevXvx3ve+F1dffTX27NmDtm2Pmfvjvcc73vEOvOY1r8EFF1wAAHO9U/v37zefL9p3NIl1jwDgF37hF3DWWWdhx44d+Na3voX3vOc9uPPOO/GpT30KwOG5R5sKuBYi5eqrr06fX/GKV+CSSy7BWWedhf/wH/4DjjvuuCM4soVsVnnDG96QPl944YV4xStegXPPPRc333wzLrvssiM4sudWdu/eje985zvCZ7wQKbV7xP2dF154IbZv347LLrsMe/fuxbnnnntYzr2pTIWnnXYa2rYtWDzf//73sW3btiM0quePnHLKKfjhH/5h3H333di2bRvW19fx2GOPiTbH8r2i6x57frZt21YQfabTKX7wgx8ck/ftnHPOwWmnnYa7774bwLFxf66//np89rOfxZ/92Z+JAofzvFPbtm0zny/ad7RI7R5ZcskllwCAeIYO9R5tKuBaXl7GRRddhC9+8Ytpm/ceX/ziF7Fr164jOLLnhzz11FPYu3cvtm/fjosuugiTyUTcqzvvvBP79u07Zu/V2WefjW3btol78sQTT+BrX/tauie7du3CY489hltvvTW1uemmm+C9Ty/gsSQPPPAAHn30UWzfvh3A0X1/Qgi4/vrr8elPfxo33XQTzj77bLF/nndq165d+Pa3vy3A/fOf/zxOOukknH/++c/NhTyLMuseWXLbbbcBgHiGDvkeHSSZ5IjJH/3RH4WVlZXwsY99LNxxxx3hrW99azjllFMEQ+VYkV/91V8NN998c7jnnnvCX/zFX4TLL788nHbaaeGRRx4JIYTwtre9LZx55pnhpptuCt/4xjfCrl27wq5du47wqJ9defLJJ8M3v/nN8M1vfjMACP/6X//r8M1vfjPcd999IYQQ/vk//+fhlFNOCZ/5zGfCt771rfCzP/uz4eyzzw7PPPNM6uOqq64Kr3rVq8LXvva18OUvfzm87GUvCz//8z9/pC7psMrY/XnyySfDr/3ar4U9e/aEe+65J3zhC18IP/7jPx5e9rKXhdXV1dTH0Xp/rrvuunDyySeHm2++OTz88MPp34EDB1KbWe/UdDoNF1xwQbjiiivCbbfdFj73uc+FF73oReGGG244Epd02GXWPbr77rvDBz/4wfCNb3wj3HPPPeEzn/lMOOecc8JrX/va1MfhuEebDrhCCOF3f/d3w5lnnhmWl5fDT/7kT4avfvWrR3pIR0R+7ud+Lmzfvj0sLy+HM844I/zcz/1cuPvuu9P+Z555JvzKr/xK+KEf+qFw/PHHh7//9/9+ePjhh4/giJ99+bM/+7MAoPj3pje9KYQQKfH/7J/9s3D66aeHlZWVcNlll4U777xT9PHoo4+Gn//5nw8veMELwkknnRT+8T/+x+HJJ588Aldz+GXs/hw4cCBcccUV4UUvelGYTCbhrLPOCm95y1uKReHRen+s+wIgfPSjH01t5nmn7r333nD11VeH4447Lpx22mnhV3/1V0PXdc/x1Tw7Muse7du3L7z2ta8Np556alhZWQkvfelLw7vf/e7w+OOPi34O9R4typosZCELWchCNpVsKh/XQhaykIUsZCEL4FrIQhaykIVsKlkA10IWspCFLGRTyQK4FrKQhSxkIZtKFsC1kIUsZCEL2VSyAK6FLGQhC1nIppIFcC1kIQtZyEI2lSyAayELWchCFrKpZAFcC1nIQhaykE0lC+BayEIWspCFbCpZANdCFrKQhSxkU8kCuBaykIUsZCGbSv5/Xfbewpnna04AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "with xarray.open_dataset(\n", + " \"https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr\",\n", + " engine=\"zarr\",\n", + " decode_coords=\"all\"\n", + ") as src:\n", + " \n", + " ds = src[\"analysed_sst\"][:1]\n", + " \n", + " # the SST dataset do not have a CRS info\n", + " # so we need to add it to `virtualy` within the Xarray DataArray\n", + " ds.rio.write_crs(\"epsg:4326\", inplace=True)\n", + " \n", + " with XarrayReader(ds) as dst:\n", + " print(dst.info())\n", + " img = dst.tile(1, 1, 2)\n", + "\n", + " plt.imshow(img.data_as_image())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4c3f03f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "py39", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + }, + "vscode": { + "interpreter": { + "hash": "2590a9e34ee6c8bdce5141410f2a072bbabd2a859a8a48acdaa85720923a90ef" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/src/examples/Using-rio-tiler-mosaic.ipynb b/docs/src/examples/Using-rio-tiler-mosaic.ipynb index 00732e40..ce25ebe0 100755 --- a/docs/src/examples/Using-rio-tiler-mosaic.ipynb +++ b/docs/src/examples/Using-rio-tiler-mosaic.ipynb @@ -35,12 +35,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Requirements\n", + "#### Requirements\n", "\n", "To be able to run this notebook you'll need the following requirements:\n", "- rasterio\n", "- ipyleaflet\n", - "- rio-tiler~= 3.0" + "- rio-tiler~= 4.0" ] }, { @@ -65,7 +65,7 @@ "\n", "import morecantile\n", "\n", - "from rio_tiler.io import COGReader, STACReader\n", + "from rio_tiler.io import Reader, STACReader\n", "from rio_tiler.mosaic import mosaic_reader\n", "from rio_tiler.mosaic.methods import defaults\n", "from rio_tiler.mosaic.methods.base import MosaicMethodBase\n", @@ -269,7 +269,7 @@ "outputs": [], "source": [ "def tiler(asset, *args, **kwargs):\n", - " with COGReader(asset) as cog:\n", + " with Reader(asset) as cog:\n", " return cog.tile(*args, **kwargs)" ] }, @@ -423,7 +423,7 @@ "# Because we need to use multiple STAC assets, it's easier to use the STACReader\n", "def custom_tiler(asset, *args, **kwargs):\n", " with STACReader(asset) as stac:\n", - " return stac.tile(*args, expression=\"(B08-B04)/(B08+B04)\")\n", + " return stac.tile(*args, expression=\"(B08_b1-B04_b1)/(B08_b1+B04_b1)\")\n", "\n", "tile = tiles[0]\n", "\n", @@ -465,7 +465,7 @@ "def custom_tiler(asset, *args, **kwargs):\n", " with STACReader(asset) as stac:\n", " img = stac.tile(*args, assets=\"visual\")\n", - " ndvi = stac.tile(*args, expression=\"(B08-B04)/(B08+B04)\")\n", + " ndvi = stac.tile(*args, expression=\"(B08_b1-B04_b1)/(B08_b1+B04_b1)\")\n", " return ImageData(numpy.concatenate((img.data, ndvi.data)), img.mask, crs=img.crs, bounds=img.bounds)" ] }, @@ -552,7 +552,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -566,7 +566,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/docs/src/examples/Using-rio-tiler.ipynb b/docs/src/examples/Using-rio-tiler.ipynb index 472f8bcd..d7cfd44f 100644 --- a/docs/src/examples/Using-rio-tiler.ipynb +++ b/docs/src/examples/Using-rio-tiler.ipynb @@ -33,7 +33,7 @@ "# Requirements\n", "\n", "To be able to run this notebook you'll need the following requirements:\n", - "- rio-tiler~= 3.0" + "- rio-tiler~= 4.0" ] }, { @@ -52,7 +52,7 @@ "outputs": [], "source": [ "import morecantile\n", - "from rio_tiler.io import COGReader\n", + "from rio_tiler.io import Reader\n", "from rio_tiler.profiles import img_profiles\n", "from rio_tiler.models import ImageData" ] @@ -61,14 +61,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Data\n", - "\n", - "For this demo we will use some NAIP data hosted on Azure.\n", - "\n", - "https://azure.microsoft.com/fr-fr/services/open-datasets/catalog/naip/\n", - "\n", - "\n", - "The data is similar to the data hosted on [AWS](https://registry.opendata.aws/naip/), but using the one on Azure is easier because it offers direct `http` access without needing an AWS account." + "\n" ] }, { @@ -78,7 +71,7 @@ "outputs": [], "source": [ "# For this DEMO we will use this file\n", - "src_path = \"https://naipblobs.blob.core.windows.net/naip/v002/al/2019/al_60cm_2019/30087/m_3008701_ne_16_060_20191115.tif\"" + "src_path = \"https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif\"" ] }, { @@ -87,7 +80,7 @@ "source": [ "## rio_tiler.io.COGReader\n", "\n", - "In `rio-tiler` 2.0 we introduced COGReader, which is a python class providing usefull methods to read and inspect any GDAL/rasterio raster dataset.\n", + "In `rio-tiler` 2.0 we introduced COGReader (renamed Reader in 4.0), which is a python class providing usefull methods to read and inspect any GDAL/rasterio raster dataset.\n", "\n", "Docs: [https://cogeotiff.github.io/rio-tiler/readers/#cogreader](https://cogeotiff.github.io/rio-tiler/readers/#cogreader) " ] @@ -98,7 +91,7 @@ "metadata": {}, "outputs": [], "source": [ - "?COGReader" + "?Reader" ] }, { @@ -120,7 +113,7 @@ "source": [ "# As for Rasterio, using context manager is a good way to \n", "# make sure the dataset are closed when we exit.\n", - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " print(\"rasterio dataset:\")\n", " print(cog.dataset)\n", " print()\n", @@ -152,12 +145,12 @@ }, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " meta = cog.statistics(max_size=256)\n", "\n", " assert isinstance(meta, dict)\n", " print(list(meta))\n", - " print(meta[\"1\"].dict())" + " print(meta[\"b1\"].dict())" ] }, { @@ -173,18 +166,8 @@ "metadata": {}, "outputs": [], "source": [ - "fig, axs = plt.subplots(1, 4, sharey=True, tight_layout=True, dpi=150)\n", - "# Red (index 1)\n", - "axs[0].plot(meta[\"1\"].histogram[1][0:-1], meta[\"1\"].histogram[0])\n", - "\n", - "# Green (index 2)\n", - "axs[1].plot(meta[\"2\"].histogram[1][0:-1], meta[\"2\"].histogram[0])\n", - "\n", - "# Blue (index 3)\n", - "axs[2].plot(meta[\"3\"].histogram[1][0:-1], meta[\"3\"].histogram[0])\n", - "\n", - "# NIR (index 4)\n", - "axs[3].plot(meta[\"4\"].histogram[1][0:-1], meta[\"4\"].histogram[0])" + "# Band 1\n", + "plot(meta[\"b1\"].histogram[1][0:-1], meta[\"b1\"].histogram[0])" ] }, { @@ -202,7 +185,7 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " # By default `preview()` will return an array with its longest dimension lower or equal to 1024px\n", " data = cog.preview()\n", " print(data.data.shape)\n", @@ -228,14 +211,17 @@ }, "outputs": [], "source": [ - "print(f\"width: {img.width}\")\n", - "print(f\"height: {img.height}\")\n", - "print(f\"bands: {img.count}\")\n", - "print(f\"crs: {img.crs}\")\n", - "print(f\"bounds: {img.bounds}\")\n", + "print(f\"width: {data.width}\")\n", + "print(f\"height: {data.height}\")\n", + "print(f\"bands: {data.count}\")\n", + "print(f\"crs: {data.crs}\")\n", + "print(f\"bounds: {data.bounds}\")\n", + "print(f\"metadata: {data.metadata}\")\n", + "print(f\"assets: {data.assets}\")\n", + "print(f\"dataset stats: {data.dataset_statistics}\") # If stored in the original dataset\n", "\n", - "print(type(img.data))\n", - "print(type(img.mask))" + "print(type(data.data))\n", + "print(type(data.mask))" ] }, { @@ -248,22 +234,54 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": false + }, "outputs": [], "source": [ "# Rasterio doesn't use the same axis order than visualization libraries (e.g matplotlib, PIL)\n", "# in order to display the data we need to change the order (using rasterio.plot.array_to_image).\n", "# the ImageData class wraps the rasterio function in the `data_as_image()` method.\n", - "print(type(img))\n", - "print(img.data.shape)\n", + "print(type(data))\n", + "print(data.data.shape)\n", "\n", - "image = img.data_as_image()\n", + "image = data.data_as_image()\n", "# data_as_image() returns a numpy.ndarray\n", "print(type(image))\n", "print(image.shape)\n", "\n", - "# Use only the first 3 bands (RGB)\n", - "imshow(image[:,:,0:3])" + "imshow(image)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi Spectral Data\n", + "\n", + "For this demo we will use some High resolution RGB-Nir data hosted on [AWS](https://registry.opendata.aws/nj-imagery/).\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "src_path = \"https://njogis-imagery.s3.amazonaws.com/2020/cog/I7D16.tif\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with Reader(src_path) as cog:\n", + " info = cog.info()\n", + " print(\"rio-tiler dataset info:\")\n", + " print(cog.info().dict(exclude_none=True))" ] }, { @@ -272,9 +290,23 @@ "metadata": {}, "outputs": [], "source": [ - "# Display NRG image\n", - "# The NAIP imagery has 4 bands Red, Green, Blue, NIR\n", - "imshow(image[:,:,[3,0,1]])" + "with Reader(src_path) as cog:\n", + " meta = cog.statistics()\n", + "\n", + "print(list(meta))\n", + " \n", + "fig, axs = plt.subplots(1, 4, sharey=True, tight_layout=True, dpi=150)\n", + "# Red (index 1)\n", + "axs[0].plot(meta[\"b1\"].histogram[1][0:-1], meta[\"b1\"].histogram[0])\n", + "\n", + "# Green (index 2)\n", + "axs[1].plot(meta[\"b2\"].histogram[1][0:-1], meta[\"b2\"].histogram[0])\n", + "\n", + "# Blue (index 3)\n", + "axs[2].plot(meta[\"b3\"].histogram[1][0:-1], meta[\"b3\"].histogram[0])\n", + "\n", + "# Nir (index 3)\n", + "axs[3].plot(meta[\"b4\"].histogram[1][0:-1], meta[\"b4\"].histogram[0])" ] }, { @@ -292,11 +324,13 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", - " # Return only the last band\n", + "with Reader(src_path) as cog:\n", + " # Return only the third band\n", " nir_band = cog.preview(indexes=4)\n", " print(nir_band.data.shape)\n", - " print(nir_band.data.dtype)" + " print(nir_band.data.dtype)\n", + "\n", + "imshow(nir_band.data_as_image())" ] }, { @@ -305,7 +339,23 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", + " # Return only the third band\n", + " nrg = cog.preview(indexes=(4,3,1))\n", + " \n", + " # Data is in Uint16 so we need to rescale\n", + " nrg.rescale(((nrg.data.min(), nrg.data.max()),))\n", + "\n", + "imshow(nrg.data_as_image())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with Reader(src_path) as cog:\n", " # Apply NDVI band math\n", " # (NIR - RED) / (NIR + RED)\n", " ndvi = cog.preview(expression=\"(b4-b1)/(b4+b1)\")\n", @@ -313,8 +363,8 @@ " print(ndvi.data.dtype)\n", " print(\"NDVI range: \", ndvi.data.min(), ndvi.data.max())\n", "\n", - "image = ndvi.post_process(in_range=((-1,1),))\n", - "imshow(image.data[0])" + "ndvi.rescale(in_range=((-1,1),))\n", + "imshow(ndvi.data_as_image())" ] }, { @@ -332,7 +382,7 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " print(f\"Bounds in dataset CRS: {cog.bounds}\")\n", " print(f\"Bounds WGS84: {cog.geographic_bounds}\")\n", " print(f\"MinZoom (WebMercator): {cog.minzoom}\")\n", @@ -351,7 +401,7 @@ "print(repr(tms))\n", "\n", "# Get the list of tiles for the COG minzoom \n", - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " tile_cover = list(tms.tiles(*cog.geographic_bounds, zooms=cog.minzoom))\n", "\n", "print(f\"Nb of Z{cog.minzoom} Mercator tiles: {len(tile_cover)}\")\n", @@ -364,11 +414,14 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " img_1 = cog.tile(*tile_cover[0])\n", + " img_1.rescale(((0, 40000),))\n", " print(img_1.data.shape)\n", "\n", " img_2 = cog.tile(*tile_cover[1])\n", + " img_2.rescale(((0, 40000),))\n", + "\n", " print(img_2.data.shape)" ] }, @@ -396,12 +449,12 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " ndvi = cog.tile(*tile_cover[0], expression=\"(b4-b1)/(b4+b1)\")\n", " print(ndvi.data.shape)\n", "\n", - "image = ndvi.post_process(in_range=((-1,1),))\n", - "imshow(image.data[0])" + "ndvi.rescale(in_range=((-1,1),))\n", + "imshow(ndvi.data[0])" ] }, { @@ -419,9 +472,9 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " # By default `part()` will read the highest resolution. We can limit this by using the `max_size` option.\n", - " img = cog.part((-87.92238235473633, 30.954131465929947, -87.87843704223633, 30.97996389724008), max_size=1024)\n", + " img = cog.part((-74.30680274963379, 40.60748547709819, -74.29478645324707, 40.61567903099978), max_size=1024)\n", " print(img.data.shape)\n", " print(img.bounds)\n", " print(img.crs)" @@ -433,6 +486,8 @@ "metadata": {}, "outputs": [], "source": [ + "img.rescale(((0, 40000),))\n", + "\n", "imshow(img.data_as_image()[:,:,0:3])" ] }, @@ -451,8 +506,8 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", - " values = cog.point(-87.92238235473633, 30.954131465929947)\n", + "with Reader(src_path) as cog:\n", + " values = cog.point(-74.30680274963379, 40.60748547709819)\n", "print(values)" ] }, @@ -471,7 +526,61 @@ "metadata": {}, "outputs": [], "source": [ - "feat = {\"type\":\"Feature\",\"properties\":{},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-87.91989326477051,30.977388327504983],[-87.92341232299805,30.9747390975502],[-87.92015075683594,30.97282571907513],[-87.91723251342773,30.971869015455276],[-87.9192066192627,30.96914603729001],[-87.92032241821289,30.965466213678003],[-87.91869163513184,30.960093416531947],[-87.91577339172363,30.957885330068873],[-87.91028022766113,30.95700208119036],[-87.90839195251465,30.955971613842785],[-87.91105270385741,30.954646710918635],[-87.91508674621582,30.954793923262105],[-87.92135238647461,30.953321789618688],[-87.92126655578612,30.947506639952913],[-87.92324066162108,30.94353152388283],[-87.9224681854248,30.9393353886492],[-87.92109489440918,30.936832343075928],[-87.92075157165527,30.9326359137502],[-87.91646003723145,30.934034743992026],[-87.91534423828124,30.937494920341457],[-87.91611671447754,30.941764752554082],[-87.91053771972656,30.943973211611713],[-87.91414260864258,30.948242754412334],[-87.91671752929688,30.949862186261473],[-87.91191101074219,30.949494135978654],[-87.90899276733398,30.950377454275596],[-87.90349960327147,30.95045106376507],[-87.90298461914062,30.953174575006617],[-87.89912223815918,30.953763432093723],[-87.89543151855469,30.953027360167685],[-87.89122581481934,30.955529981576515],[-87.89551734924316,30.959651803323208],[-87.89912223815918,30.960903035444577],[-87.90238380432129,30.95979900795299],[-87.90633201599121,30.96053502769875],[-87.9082202911377,30.963479049959364],[-87.91345596313477,30.964877428739207],[-87.91259765625,30.967306143211744],[-87.9085636138916,30.965466213678003],[-87.90547370910643,30.96553981154008],[-87.90667533874512,30.96885165662014],[-87.90684700012207,30.97039714501039],[-87.89517402648926,30.972972903396382],[-87.89328575134277,30.97643166961476],[-87.8957748413086,30.979080852589725],[-87.89852142333984,30.977093972252376],[-87.90006637573242,30.97643166961476],[-87.9019546508789,30.978712914907245],[-87.90633201599121,30.97805062350409],[-87.90461540222168,30.975107050552193],[-87.90521621704102,30.97422396096446],[-87.90796279907227,30.976358080149122],[-87.90976524353026,30.976063721719164],[-87.90907859802245,30.973856004558257],[-87.9111385345459,30.974076778572197],[-87.91379928588867,30.975769362381378],[-87.9177474975586,30.97643166961476],[-87.91929244995116,30.977314738776947],[-87.91989326477051,30.977388327504983]]]}}" + "feat = {\n", + " \"type\": \"Feature\",\n", + " \"properties\": {},\n", + " \"geometry\": {\n", + " \"type\": \"Polygon\",\n", + " \"coordinates\": [\n", + " [\n", + " [\n", + " -74.30384159088135,\n", + " 40.614245638811646\n", + " ],\n", + " [\n", + " -74.30680274963379,\n", + " 40.61121586776988\n", + " ],\n", + " [\n", + " -74.30590152740477,\n", + " 40.608967884350946\n", + " ],\n", + " [\n", + " -74.30272579193115,\n", + " 40.60748547709819\n", + " ],\n", + " [\n", + " -74.29875612258911,\n", + " 40.60786015456402\n", + " ],\n", + " [\n", + " -74.2960524559021,\n", + " 40.61012446497514\n", + " ],\n", + " [\n", + " -74.29478645324707,\n", + " 40.61390357476733\n", + " ],\n", + " [\n", + " -74.29882049560547,\n", + " 40.61515780103489\n", + " ],\n", + " [\n", + " -74.30294036865233,\n", + " 40.61567903099978\n", + " ],\n", + " [\n", + " -74.3035626411438,\n", + " 40.61502749290829\n", + " ],\n", + " [\n", + " -74.30384159088135,\n", + " 40.614245638811646\n", + " ]\n", + " ]\n", + " ]\n", + " }\n", + "}" ] }, { @@ -480,7 +589,7 @@ "metadata": {}, "outputs": [], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " # we use the feature to define the bounds and the mask\n", " # but we use `dst_crs` options to keep the projection from the input dataset\n", " # By default `feature()` will read the highest resolution. We can limit this by using the `max_size` option.\n", @@ -498,15 +607,9 @@ }, "outputs": [], "source": [ + "img.rescale(((0, 40000),))\n", "imshow(img.data_as_image()[:,:,0:3])" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -514,7 +617,8 @@ "hash": "e5a596c8625da0593f23bdd5ea51ce5c4572779fa5edc69fb6a18fc94feb7fb6" }, "kernelspec": { - "display_name": "Python 3.8.2 64-bit", + "display_name": "Python 3 (ipykernel)", + "language": "python", "name": "python3" }, "language_info": { @@ -527,7 +631,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/docs/src/examples/Using-tms.ipynb b/docs/src/examples/Using-tms.ipynb index 7e88260c..5cb3a94e 100644 --- a/docs/src/examples/Using-tms.ipynb +++ b/docs/src/examples/Using-tms.ipynb @@ -21,13 +21,13 @@ "# Requirements\n", "\n", "To be able to run this notebook you'll need the following requirements:\n", - "- rio-tiler~= 3.0\n", + "- rio-tiler~= 4.0\n", "- ipyleaflet" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -37,7 +37,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -59,22 +59,17 @@ "source": [ "## Data\n", "\n", - "For this demo we will use some NAIP data hosted on Azure.\n", - "\n", - "https://azure.microsoft.com/fr-fr/services/open-datasets/catalog/naip/\n", - "\n", - "\n", - "The data is similar to the data hosted on [AWS](https://registry.opendata.aws/naip/), but using the one on Azure is easier because it offers direct `http` access without needing an AWS account." + "For this demo we will use some High resolution RGB-Nir data hosted on [AWS](https://registry.opendata.aws/nj-imagery/)." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "# For this DEMO we will use this file\n", - "src_path = \"https://naipblobs.blob.core.windows.net/naip/v002/al/2019/al_60cm_2019/30087/m_3008701_ne_16_060_20191115.tif\"" + "src_path = \"https://njogis-imagery.s3.amazonaws.com/2020/cog/I7D16.tif\"" ] }, { @@ -88,7 +83,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -100,7 +95,7 @@ "from tornado.httpserver import HTTPServer\n", "from tornado.concurrent import run_on_executor\n", "\n", - "from rio_tiler.io import COGReader\n", + "from rio_tiler.io import Reader\n", "from rio_tiler.errors import TileOutsideBounds\n", "from rio_tiler.profiles import img_profiles\n", "\n", @@ -137,7 +132,7 @@ " def _get_tile(self, tms, z, x, y):\n", "\n", " try:\n", - " with COGReader(self.url, tms=morecantile.tms.get(tms)) as cog:\n", + " with Reader(self.url, tms=morecantile.tms.get(tms)) as cog:\n", " img = cog.tile(x, y, z, indexes=(1,2,3))\n", " except TileOutsideBounds:\n", " raise web.HTTPError(404)\n", @@ -169,11 +164,31 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": { "scrolled": false }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Supported TMS:\n", + "- LINZAntarticaMapTilegrid | urn:ogc:def:crs:EPSG::5482\n", + "- EuropeanETRS89_LAEAQuad | urn:ogc:def:crs:EPSG::3035\n", + "- CanadianNAD83_LCC | urn:ogc:def:crs:EPSG::3978\n", + "- UPSArcticWGS84Quad | urn:ogc:def:crs:EPSG::5041\n", + "- NZTM2000 | urn:ogc:def:crs:EPSG::2193\n", + "- NZTM2000Quad | urn:ogc:def:crs:EPSG::2193\n", + "- UTM31WGS84Quad | urn:ogc:def:crs:EPSG::32631\n", + "- UPSAntarcticWGS84Quad | urn:ogc:def:crs:EPSG::5042\n", + "- WorldMercatorWGS84Quad | urn:ogc:def:crs:EPSG::3395\n", + "- WGS1984Quad | urn:ogc:def:crs:EPSG::4326\n", + "- WorldCRS84Quad | urn:ogc:def:crs:OGC::CRS84\n", + "- WebMercatorQuad | urn:ogc:def:crs:EPSG::3857\n" + ] + } + ], "source": [ "print(\"Supported TMS:\")\n", "for name, tms in morecantile.tms.tms.items():\n", @@ -189,15 +204,41 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'bounds': BoundingBox(left=-74.3095632062702, bottom=40.603994417539994, right=-74.29151245384847, top=40.61775082944064), 'minzoom': 14, 'maxzoom': 19, 'band_metadata': [('b1', {}), ('b2', {}), ('b3', {}), ('b4', {})], 'band_descriptions': [('b1', ''), ('b2', ''), ('b3', ''), ('b4', '')], 'dtype': 'uint16', 'nodata_type': 'None', 'colorinterp': ['red', 'green', 'blue', 'undefined'], 'driver': 'GTiff', 'count': 4, 'overviews': [2, 4, 8, 16], 'width': 5000, 'height': 5000}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "7b640ef0c2b34a37902653915778514d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Map(center=[40.610872623490316, -74.30053783005934], controls=(ZoomControl(options=['position', 'zoom_in_text'…" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "with COGReader(src_path) as cog:\n", + "with Reader(src_path) as cog:\n", " info = cog.info()\n", + " \n", "print(info.dict(exclude_none=True))\n", "\n", - "m = Map(center=(30.96, -87.90), zoom=info.minzoom, basemap={})\n", + "bounds = info.bounds\n", + "center = ((bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2)\n", + "m = Map(center=center, zoom=info.minzoom, basemap={})\n", "\n", "layer = TileLayer(\n", " url=\"http://127.0.0.1:8080/tiles/WebMercatorQuad/{z}/{x}/{y}\",\n", @@ -224,11 +265,13 @@ }, "outputs": [], "source": [ - "with COGReader(src_path, tms=morecantile.tms.get(\"WorldCRS84Quad\")) as cog:\n", + "with Reader(src_path, tms=morecantile.tms.get(\"WorldCRS84Quad\")) as cog:\n", " info = cog.info()\n", "print(info.dict(exclude_none=True))\n", "\n", - "m = Map(center=(30.96, -87.90), zoom=info.minzoom, basemap={}, crs=projections.EPSG4326)\n", + "bounds = info.bounds\n", + "center = ((bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2)\n", + "m = Map(center=center, zoom=info.minzoom, basemap={}, crs=projections.EPSG4326)\n", "\n", "layer = TileLayer(\n", " url=\"http://127.0.0.1:8080/tiles/WorldCRS84Quad/{z}/{x}/{y}\",\n", @@ -242,7 +285,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -259,7 +302,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -273,7 +316,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/docs/src/intro.md b/docs/src/intro.md index 490dbea1..0049cbc6 100644 --- a/docs/src/intro.md +++ b/docs/src/intro.md @@ -1,20 +1,24 @@ ## Read data +`rio-tiler` has **Readers** classes which have methods to access data in `Tile`, `Part` (bbox), `Feature` (GeoJSON), `Point` (lon, lat) or as a whole. + +Here is a quick overview of how to use rio-tiler's main reader `rio_tiler.io.rasterio.Reader`: + ```python -from rio_tiler.io import COGReader -from rio_tiler.models import ImageData +from rio_tiler.io import Reader +from rio_tiler.models import ImageData, PointData tile_x = 691559 tile_y = 956905 tile_zoom = 21 -with COGReader( +with Reader( "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" -) as cog: +) as dst: # Read data for a slippy map tile - img = cog.tile(tile_x, tile_y, tile_zoom, tilesize=256) - assert isinstance(img, ImageData) + img = dst.tile(tile_x, tile_y, tile_zoom, tilesize=256) + assert isinstance(img, ImageData) # Image methods return data as rio_tiler.models.ImageData object print(img.data.shape) >>> (3, 256, 256) @@ -22,68 +26,68 @@ with COGReader( >>> (256, 256) # Read the entire data - img = cog.read() + img = dst.read() print(img.data.shape) >>> (3, 11666, 19836) # Read part of a data for a given bbox (we use `max_size=1024` to limit the data transfer and read lower resolution data) - img = cog.part([-61.281, 15.539, -61.279, 15.541], max_size=1024) + img = dst.part([-61.281, 15.539, -61.279, 15.541], max_size=1024) print(img.data.shape) >>> (3, 1024, 1024) # Read data for a given geojson polygon (we use `max_size=1024` to limit the data transfer and read lower resolution data) - img = cog.feature(geojson_feature, max_size=1024) + img = dst.feature(geojson_feature, max_size=1024) # Get a preview (size is maxed out to 1024 by default to limit the data transfer and read lower resolution data) - img = cog.preview() + img = dst.preview() print(img.data.shape) >>> (3, 603, 1024) # Get pixel values for a given lon/lat coordinate - values = cog.point(-61.281, 15.539) - print(values) + values = dst.point(-61.281, 15.539) + assert isinstance(img, PointData) # Point methods return data as rio_tiler.models.PointData object + print(values.data) >>> [47, 62, 43] - - # You can also use the `old style` notation - data, mask = cog.tile(691559, 956905, 21, tilesize=256) - print(data.shape) - >>> (3, 256, 256) - print(mask.shape) - >>> (256, 256) ``` -The `COGReader` class has other interesting features, please see [User Guide - Readers](readers.md). +The `rio_tiler.io.rasterio.Reader` class has other interesting features, please see [User Guide - Readers](readers.md). ## Render the data as an image (PNG/JPEG) ```python -with COGReader( +with Reader( "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" -) as cog: - img = cog.tile(691559, 956905, 21, tilesize=256) - buff = img.render() # this returns a buffer (PNG by default) +) as dst: + img = dst.tile(691559, 956905, 21, tilesize=256) + + # Encode the data in PNG (default) + buff = img.render() + + # Encode the data in JPEG + buff = img.render(img_format="JPEG") ``` ## Rescale non-byte data and/or apply colormap ```python -with COGReader( - "s3://landsat-pds/c1/L8/015/029/LC08_L1GT_015029_20200119_20200119_01_RT/LC08_L1GT_015029_20200119_20200119_01_RT_B8.TIF", - nodata=0, -) as cog: - img = cog.tile(150, 187, 9) +from rio_tiler.colormap import cmap + +# Get Colormap +cm = cmap.get("viridis") + +with Reader( + "https://sentinel-cogs.s3.amazonaws.com/sentinel-s2-l2a-cogs/29/R/KH/2020/2/S2A_29RKH_20200219_0_L2A/B01.tif", +) as dst: + img = dst.tile(239, 220, 9) # Rescale the data from 0-10000 to 0-255 - image_rescale = img.post_process( + img.rescale( in_range=((0, 10000),), out_range=((0, 255),), ) - # Get Colormap - cm = cmap.get("viridis") - # Apply colormap and create a PNG buffer - buff = image_rescale.render(colormap=cm) # this returns a buffer (PNG by default) + buff = img.render(colormap=cm) # this returns a buffer (PNG by default) ``` ## Use creation options to match `mapnik` defaults. @@ -91,10 +95,10 @@ with COGReader( ```python from rio_tiler.profiles import img_profiles -with COGReader( +with Reader( "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" -) as cog: - img = cog.tile(691559, 956905, 21, tilesize=256) +) as dst: + img = dst.tile(691559, 956905, 21, tilesize=256) options = img_profiles.get("webp") @@ -119,16 +123,15 @@ with open("my.png", "wb") as f: You can also export image data to a numpy binary format (`NPY`). ```python -with COGReader( - "s3://landsat-pds/c1/L8/015/029/LC08_L1GT_015029_20200119_20200119_01_RT/LC08_L1GT_015029_20200119_20200119_01_RT_B8.TIF", - nodata=0, -) as cog: - img = cog.tile(150, 187, 9) +with Reader( + "https://sentinel-cogs.s3.amazonaws.com/sentinel-s2-l2a-cogs/29/R/KH/2020/2/S2A_29RKH_20200219_0_L2A/B01.tif", +) as dst: + img = dst.tile(239, 220, 9) buff = img.render(img_format="npy") npy_tile = numpy.load(BytesIO(buff)) - assert npy_tile.shape == (2, 256, 256) # mask is appened to the end of the data + assert npy_tile.shape == (2, 256, 256) # mask is added to the end of the data buff = img.render(img_format="npy", add_mask=False) diff --git a/docs/src/models.md b/docs/src/models.md index f0ec045a..dc6423c4 100644 --- a/docs/src/models.md +++ b/docs/src/models.md @@ -14,6 +14,7 @@ This class has helper methods like `render` which forward internal data and mask - **crs**: coordinate reference system for the data ([rasterio.crs.CRS](https://github.com/rasterio/rasterio/blob/master/rasterio/crs.py#L21), optional) - **metadata**: additional metadata (dict, optional) - **band_names**: image band's names +- **dataset_statistics**: Dataset's min/max values (list of (min,max), optional) ```python import numpy @@ -30,7 +31,7 @@ print(ImageData(d, m)) bounds=None, crs=None, metadata={}, - band_names=['1', '2', '3'], + band_names=['b1', 'b2', 'b3'], ) ``` @@ -76,7 +77,7 @@ print(image.shape) >>> (256, 256, 3) ``` -- **post_process()**: Apply rescaling or/and rio-color formula to the data array. Returns a new ImageData instance. +- **post_process**: Apply rescaling or/and rio-color formula to the data array. Returns a new ImageData instance. ```python import numpy @@ -116,6 +117,80 @@ image = img.post_process( assert isinstance(image, ImageData) ``` +- **rescale()**: linear rescaling of the data in place + +!!! info "New in version 3.1.5" + +```python +import numpy +from rio_tiler.models import ImageData + +d = numpy.random.randint(0, 3000, (3, 256, 256)) +m = numpy.zeros((256, 256)) + 255 + +img = ImageData(d, m) + +print(img.data.dtype) +>>> 'int64' + +print(img.data.max()) +>>> 2999 + +# rescale and apply rio-color formula +img.rescale(in_range=((0, 3000),),) +print(img.data.max()) +>>> 254 + +print(img.data.dtype) +>>> 'uint8' +``` + +- **apply_color_formula()**: Apply `rio-color`'s color formula in place + +!!! info "New in version 3.1.5" + +```python +import numpy +from rio_tiler.models import ImageData + +d = numpy.random.randint(0, 3000, (3, 256, 256)) +m = numpy.zeros((256, 256)) + 255 + +img = ImageData(d, m) + +print(img.data.dtype) +>>> 'int64' + +img.apply_color_formula("Gamma RGB 3.1") +print(img.data.dtype) +>>> 'uint8' +``` + +- **apply_expression()**: Apply band math expression + +!!! info "New in version 4.0" + +```python +import numpy +from rio_tiler.models import ImageData + +d = numpy.random.randint(0, 3000, (3, 256, 256)) +m = numpy.zeros((256, 256)) + 255 + +img = ImageData(d, m) +print(img.band_names) +>>> ["b1", "b2", "b3"] # Defaults + +ratio = img.apply_expression("b1/b2") # Returns a new ImageData object +assert isinstance(ratio, ImageData) + +print(ratio.band_names) +>>> ["b1/b2"] + +print(ratio.data.shape) +>>> (1, 256, 256) +``` + - **render()**: Render the data/mask to an image buffer (forward data and mask to rio_tiler.utils.render). ```python @@ -165,14 +240,91 @@ print(get_meta(buf)) Note: Starting with `rio-tiler==2.1`, when the output datatype is not valid for a driver (e.g `float` for `PNG`), `rio-tiler` will automatically rescale the data using the `min/max` value for the datatype (ref: https://github.com/cogeotiff/rio-tiler/pull/391). + +## PointData + +!!! info "New in version 4.0" + +#### Attributes + +- **data**: point array (numpy.ndarray) +- **mask**: gdal/rasterio mask array (numpy.ndarray) +- **assets**: assets list used to create the data array (list, optional) +- **coordinates**: Coordinates of the point (Tuple[float, float], optional) +- **crs**: coordinate reference system for the data ([rasterio.crs.CRS](https://github.com/rasterio/rasterio/blob/master/rasterio/crs.py#L21), optional) +- **metadata**: additional metadata (dict, optional) +- **band_names**: values band's names + +```python +import numpy +from rio_tiler.models import PointData + +d = numpy.zeros((3)) +m = numpy.zeros((1), dtype="uint8") + 255 + +print(PointData(d, m)) +>>> PointData( + data=array([0., 0., 0.]), + mask=array([255]), + assets=None, + coordinates=None, + crs=None, + metadata={}, + band_names=['b1', 'b2', 'b3'], +) +``` + +#### Properties + +- **count**: number of bands in the data array (int) + +#### Methods + +- **as_masked()**: Return the data array as a `numpy.ma.MaskedArray` + +```python +import numpy +from rio_tiler.models import PointData + +d = numpy.zeros((3)) +m = numpy.zeros((1), dtype="uint8") + 255 + +masked = PointData(d, m).as_masked() +print(type(masked)) +>>> numpy.ma.core.MaskedArray +``` + +- **apply_expression()**: Apply band math expression + +```python +import numpy +from rio_tiler.models import PointData + +d = numpy.random.randint(0, 3000, (3)) +m = numpy.zeros((1), dtype="uint8") + 255 + +pts = PointData(d, m) +print(pts.band_names) +>>> ["b1", "b2", "b3"] # Defaults + +ratio = pts.apply_expression("b1/b2") # Returns a new PointData object +assert isinstance(ratio, PointData) + +print(ratio.band_names) +>>> ["b1/b2"] + +print(ratio.count) +>>> 1 +``` + ## Others -Readers methods (`info`, `metadata` and `stats`) returning metadata like results return [pydantic](https://pydantic-docs.helpmanual.io) models to make sure the values are valids. +Readers methods returning metadata like results (`info()` and `statistics()`) return [pydantic](https://pydantic-docs.helpmanual.io) models to make sure the values are valids. ### Info ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import Info # Schema @@ -308,10 +460,10 @@ print(Info.schema()) } # Example -with COGReader( +with Reader( "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" -) as cog: - info = cog.info() +) as src: + info = src.info() print(info["nodata_type"]) >>> "None" @@ -324,8 +476,8 @@ print(info.json(exclude_none=True)) 'bounds': [-61.287001876638215, 15.537756794450583, -61.27877967704677, 15.542486503997608], 'minzoom': 16, 'maxzoom': 22, - 'band_metadata': [('1', {}), ('2', {}), ('3', {})], - 'band_descriptions': [('1', ''), ('2', ''), ('3', '')], + 'band_metadata': [('b1', {}), ('b2', {}), ('b3', {})], + 'band_descriptions': [('b1', ''), ('b2', ''), ('b3', '')], 'dtype': 'uint8', 'nodata_type': 'None', 'colorinterp': ['red', 'green', 'blue'], @@ -337,12 +489,12 @@ print(info.json(exclude_none=True)) } ``` -Note: starting with `rio-tiler>=2.0.8`, additional metadata can be set (e.g. driver, count, width, height, overviews in `COGReader.info()`) +Note: starting with `rio-tiler>=2.0.8`, additional metadata can be set (e.g. driver, count, width, height, overviews in `Reader.info()`) ### BandStatistics ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import BandStatistics # Schema @@ -441,19 +593,19 @@ print(BandStatistics.schema()) } # Example -with COGReader( +with Reader( "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" -) as cog: - stats = cog.statistics() - assert isinstance(stats["1"], BandStatistics) +) as src: + stats = src.statistics() + assert isinstance(stats["b1"], BandStatistics) -print(stats["1"]["min"]) +print(stats["b1"]["min"]) >>> 0.0 -print(stats["1"].min) +print(stats["b1"].min) >>> 0.0 -print(stats["1"].json(exclude_none=True)) +print(stats["b1"].json(exclude_none=True)) >>> { "min": 0, "max": 255, diff --git a/docs/src/mosaic.md b/docs/src/mosaic.md index 8fa6bce6..f0b579d8 100644 --- a/docs/src/mosaic.md +++ b/docs/src/mosaic.md @@ -53,14 +53,14 @@ Returns: ### Examples ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.mosaic import mosaic_reader from rio_tiler.mosaic.methods import defaults from rio_tiler.models import ImageData def tiler(src_path: str, *args, **kwargs) -> ImageData: - with COGReader(src_path) as cog: + with Reader(src_path) as cog: return cog.tile(*args, **kwargs) mosaic_assets = ["mytif1.tif", "mytif2.tif", "mytif3.tif"] diff --git a/docs/src/readers.md b/docs/src/readers.md index b23a2f6b..e97588b9 100644 --- a/docs/src/readers.md +++ b/docs/src/readers.md @@ -1,16 +1,16 @@ -`rio-tiler`'s COGReader and STACReader are built from its abstract base classes (`AsyncBaseReader`, `BaseReader`, `MultiBandReader`, `MultiBaseReader`). Those Classes implements defaults interfaces which helps the integration in broader application. To learn more about `rio-tiler`'s base classes see [Base classes and custom readers](advanced/custom_readers.md) +`rio-tiler`'s Reader are built from its abstract base classes (`BaseReader`, `MultiBandReader`, `MultiBaseReader`). Those Classes implements defaults interfaces which helps the integration in broader application. To learn more about `rio-tiler`'s base classes see [Base classes and custom readers](advanced/custom_readers.md) -## rio_tiler.io.COGReader +## rio_tiler.io.rasterio.Reader -The `COGReader` is designed to work with simple raster datasets (e.g COG, GeoTIFF, ...). +The `Reader` is designed to work with simple raster datasets (e.g COG, GeoTIFF, ...). The class is derived from the `rio_tiler.io.base.BaseReader` base class. ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader -COGReader.__mro__ ->>> (rio_tiler.io.cogeo.COGReader, +Reader.__mro__ +>>> (rio_tiler.io.rasterio.Reader, rio_tiler.io.base.BaseReader, rio_tiler.io.base.SpatialMixin, object) @@ -21,29 +21,30 @@ COGReader.__mro__ - **input** (str): filepath - **dataset** (rasterio dataset, optional): rasterio opened dataset - **tms** (morecantile.TileMatrixSet, optional): morecantile TileMatrixSet used for tile reading (defaults to WebMercator) -- **minzoom** (int, optional): dataset's minimum zoom level (for input tms) -- **maxzoom** (int, optional): dataset's maximum zoom level (for input tms) +- **geographic_crs** (rasterio.crs.CRS, optional): CRS to use to calculate the geographic bounds (default to WGS84) - **colormap** (dict, optional): dataset's colormap +- **options** (rio_tiler.reader.Options, optional): Options to forward to rio_tiler.reader functions (e.g nodata, vrt_options, resampling) #### Properties - **bounds**: dataset's bounds (in dataset crs) - **crs**: dataset's crs - **geographic_bounds**: dataset's bounds in WGS84 - +- **minzoom**: dataset minzoom (in TMS) +- **maxzoom**: dataset maxzoom (in TMS) ```python -from rio_tiler.io import COGReader - -with COGReader("myfile.tif") as cog: - print(cog.dataset) - print(cog.tms.identifier) - print(cog.minzoom) - print(cog.maxzoom) - print(cog.bounds) - print(cog.crs) - print(cog.geographic_bounds) - print(cog.colormap) +from rio_tiler.io import Reader + +with Reader("myfile.tif") as src: + print(src.dataset) + print(src.tms.identifier) + print(src.minzoom) + print(src.maxzoom) + print(src.bounds) + print(src.crs) + print(src.geographic_bounds) + print(src.colormap) >> WebMercatorQuad @@ -60,51 +61,53 @@ EPSG:32620 - **read()**: Read the entire dataset ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData -with COGReader("myfile.tif") as cog: - img = cog.read() +with Reader("myfile.tif") as src: + img = src.read() assert isinstance(img, ImageData) - assert img.crs == cog.dataset.crs + assert img.crs == src.dataset.crs assert img.assets == ["myfile.tif"] - assert img.width == cog.dataset.width - assert img.height == cog.dataset.height - assert img.count == cog.dataset.count + assert img.width == src.dataset.width + assert img.height == src.dataset.height + assert img.count == src.dataset.count # With indexes -with COGReader("myfile.tif") as cog: - img = cog.read(indexes=1) # or cog.read(indexes=(1,)) +with Reader("myfile.tif") as src: + img = src.read(indexes=1) # or src.read(indexes=(1,)) assert img.count == 1 + assert img.band_names == ["b1"] # With expression -with COGReader("myfile.tif") as cog: - img = cog.read(expression="B1/B2") +with Reader("myfile.tif") as src: + img = src.read(expression="b1/b2") assert img.count == 1 + assert img.band_names == ["b1/b2"] ``` - **tile()**: Read map tile from a raster ```python from rio_tiler.contants import WEB_MERCATOR_CRS -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData -with COGReader("myfile.tif") as cog: - # cog.tile(tile_x, tile_y, tile_z, **kwargs) - img = cog.tile(1, 2, 3, tilesize=256) +with Reader("myfile.tif") as src: + # src.tile(tile_x, tile_y, tile_z, **kwargs) + img = src.tile(1, 2, 3, tilesize=256) assert isinstance(img, ImageData) assert img.crs == WEB_MERCATOR_CRS assert img.assets == ["myfile.tif"] # With indexes -with COGReader("myfile.tif") as cog: - img = cog.tile(1, 2, 3, tilesize=256, indexes=1) +with Reader("myfile.tif") as src: + img = src.tile(1, 2, 3, tilesize=256, indexes=1) assert img.count == 1 # With expression -with COGReader("myfile.tif") as cog: - img = cog.tile(1, 2, 3, tilesize=256, expression="B1/B2") +with Reader("myfile.tif") as src: + img = src.tile(1, 2, 3, tilesize=256, expression="B1/B2") assert img.count == 1 # Using buffer @@ -112,14 +115,14 @@ with COGReader("myfile.tif") as cog: # ref: # - https://github.com/cogeotiff/rio-tiler/issues/365 # - https://github.com/cogeotiff/rio-tiler/pull/405 -with COGReader("myfile.tif") as cog: +with Reader("myfile.tif") as src: # add 0.5 pixel on each side of the tile - img = cog.tile(1, 2, 3, tile_buffer=0.5) + img = src.tile(1, 2, 3, buffer=0.5) assert img.width == 257 assert img.height == 257 # add 1 pixel on each side of the tile - img = cog.tile(1, 2, 3, tile_buffer=1) + img = src.tile(1, 2, 3, buffer=1) assert img.width == 258 assert img.height == 258 ``` @@ -127,40 +130,40 @@ with COGReader("myfile.tif") as cog: - **part()**: Read a raster for a given bounding box (`bbox`). By default the bbox is considered to be in WGS84. ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData -with COGReader("myfile.tif") as cog: - # cog.part((minx, miny, maxx, maxy), **kwargs) - img = cog.part((10, 10, 20, 20)) +with Reader("myfile.tif") as src: + # src.part((minx, miny, maxx, maxy), **kwargs) + img = src.part((10, 10, 20, 20)) assert isinstance(img, ImageData) assert img.crs == WGS84_CRS assert img.assets == ["myfile.tif"] assert img.bounds == (10, 10, 20, 20) -# Pass bbox in WGS84 (default) but return data in the input COG CRS -with COGReader("myfile.tif") as cog: - img = cog.part((10, 10, 20, 20), dst_crs=cog.dataset.crs) - assert img.crs == cog.dataset.crs +# Pass bbox in WGS84 (default) but return data in the input dataset CRS +with Reader("myfile.tif") as src: + img = src.part((10, 10, 20, 20), dst_crs=src.dataset.crs) + assert img.crs == src.dataset.crs # Limit output size -with COGReader("myfile.tif") as cog: - img = cog.part((10, 10, 20, 20), max_size=2000) +with Reader("myfile.tif") as src: + img = src.part((10, 10, 20, 20), max_size=2000) # With indexes -with COGReader("myfile.tif") as cog: - img = cog.part((10, 10, 20, 20), indexes=1) +with Reader("myfile.tif") as src: + img = src.part((10, 10, 20, 20), indexes=1) # With expression -with COGReader("myfile.tif") as cog: - img = cog.part((10, 10, 20, 20), expression="B1/B2") +with Reader("myfile.tif") as src: + img = src.part((10, 10, 20, 20), expression="b1/b2") ``` - **feature()**: Read a raster for a geojson feature. By default the feature is considered to be in WGS84. ```python from rio_tiler.constants import WGS84_CRS -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData feat = { @@ -180,83 +183,87 @@ feat = { }, } -with COGReader("myfile.tif") as cog: - # cog.part(geojson_feature, **kwargs) - img = cog.feature(feat) +with Reader("myfile.tif") as src: + # src.part(geojson_feature, **kwargs) + img = src.feature(feat) assert isinstance(img, ImageData) assert img.crs == WGS84_CRS assert img.assets == ["myfile.tif"] assert img.bounds == (-55.61, 72.36, -53.83, 73.05) # bbox of the input feature -# Pass bbox in WGS84 (default) but return data in the input COG CRS -with COGReader("myfile.tif") as cog: - img = cog.feature(feat, dst_crs=cog.dataset.crs) - assert img.crs == cog.dataset.crs +# Pass bbox in WGS84 (default) but return data in the input dataset CRS +with Reader("myfile.tif") as src: + img = src.feature(feat, dst_crs=src.dataset.crs) + assert img.crs == src.dataset.crs # Limit output size -with COGReader("myfile.tif") as cog: - img = cog.feature(feat, max_size=2000) +with Reader("myfile.tif") as src: + img = src.feature(feat, max_size=2000) # Read high resolution -with COGReader("myfile.tif") as cog: - img = cog.feature(feat, max_size=None) +with Reader("myfile.tif") as src: + img = src.feature(feat, max_size=None) # With indexes -with COGReader("myfile.tif") as cog: - img = cog.feature(feat, indexes=1) +with Reader("myfile.tif") as src: + img = src.feature(feat, indexes=1) # With expression -with COGReader("myfile.tif") as cog: - img = cog.feature(feat, expression="B1/B2") +with Reader("myfile.tif") as src: + img = src.feature(feat, expression="b1/b2") ``` - **preview()**: Read a preview of a raster ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import ImageData -with COGReader("myfile.tif") as cog: - img = cog.preview() +with Reader("myfile.tif") as src: + img = src.preview() assert isinstance(img, ImageData) # With indexes -with COGReader("myfile.tif") as cog: - img = cog.preview(indexes=1) +with Reader("myfile.tif") as src: + img = src.preview(indexes=1) # With expression -with COGReader("myfile.tif") as cog: - img = cog.preview(expression="B1+2,B1*4") +with Reader("myfile.tif") as src: + img = src.preview(expression="b1+2;b1*4") ``` -- **point()**: Read the pixel values of a raster for a given `lon, lat` coordinates. By default the coordinates are considered to be in WGS84. +- **point()**: Read the pixel values of a raster for a given `lon, lat` coordinates. By default the coordinates are considered to be in WGS84. ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader +from rio_tiler.models import PointData -with COGReader("myfile.tif") as cog: - # cog.point(lon, lat) - print(cog.point(-100, 25)) +with Reader("myfile.tif") as src: + # src.point(lon, lat) + pt = src.point(-100, 25) + assert isinstance(pt, PointData) # With indexes -with COGReader("myfile.tif") as cog: - print(cog.point(-100, 25, indexes=1)) +with Reader("myfile.tif") as src: + pt = src.point(-100, 25, indexes=1) + print(pt.data) >>> [1] # With expression -with COGReader("myfile.tif") as cog: - print(cog.point(-100, 25, expression="B1+2,B1*4")) +with Reader("myfile.tif") as src: + pt = src.point(-100, 25, expression="b1+2;b1*4") + print(pt.data) >>> [3, 4] ``` - **info()**: Return simple metadata about the dataset ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from rio_tiler.models import Info -with COGReader("myfile.tif") as cog: - info = cog.info() +with Reader("myfile.tif") as src: + info = src.info() assert isinstance(info, Info) print(info.dict(exclude_none=True)) @@ -264,8 +271,8 @@ print(info.dict(exclude_none=True)) "bounds": [-119.05915661478785, 13.102845359730287, -84.91821332299578, 33.995073647795806], "minzoom": 3, "maxzoom": 12, - "band_metadata": [["1", {}]], - "band_descriptions": [["1", ""]], + "band_metadata": [["b1", {}]], + "band_descriptions": [["b1", ""]], "dtype": "int8", "colorinterp": ["palette"], "nodata_type": "Nodata", @@ -285,21 +292,21 @@ print(info.dict(exclude_none=True)) - **statistics()**: Return image statistics (Min/Max/Stdev) ```python -from rio_tiler.io import COGReader +from rio_tiler.io import Reader -with COGReader("myfile.tif") as cog: - stats = cog.statistics() +with Reader("myfile.tif") as src: + stats = src.statistics() assert isinstance(stats, dict) # stats will be in form or {"band": BandStatistics(), ...} print(stats) >>> { - '1': BandStatistics(...), - '2': BandStatistics(...), - '3': BandStatistics(...) + 'b1': BandStatistics(...), + 'b2': BandStatistics(...), + 'b3': BandStatistics(...) } -print(stats["1"].dict()) +print(stats["b1"].dict()) >>> { "min": 1, "max": 7872, @@ -322,16 +329,16 @@ print(stats["1"].dict()) "percentile_2": 1 } -with COGReader("myfile_with_colormap.tif") as cog: - stats = cog.statistics(categorical=True, categories=[1, 2]) # we limit the categories to 2 defined value (defaults to all dataset values) +with Reader("myfile_with_colormap.tif") as src: + stats = src.statistics(categorical=True, categories=[1, 2]) # we limit the categories to 2 defined value (defaults to all dataset values) assert isinstance(stats, dict) print(stats) >>> { - '1': BandStatistics(...) + 'b1': BandStatistics(...) } # For categorical data, the histogram will represent the density of EACH value. -print(stats["1"].dict()) +print(stats["b1"].dict()) >>> { ... "histogram": [ @@ -344,7 +351,7 @@ print(stats["1"].dict()) #### Read Options -`COGReader` accepts several input options which will be forwarded to the `rio_tiler.reader.read` function (low level function accessing the data), those options can be set as reader's attribute or within each method calls: +`Reader` accepts several input options which will be forwarded to the `rio_tiler.reader.read` function (low level function accessing the data), those options can be set as reader's attribute or within each method calls: - **nodata**: Overwrite the nodata value (or set if not present) - **unscale**: Apply internal rescaling factors @@ -353,16 +360,16 @@ print(stats["1"].dict()) - **post_process**: Function to apply after the read operations ```python -with COGReader("my_cog.tif", nodata=0) as cog: - cog.tile(1, 1, 1) +with Reader("my_cog.tif", options={"nodata": 0}) as src: + src.tile(1, 1, 1) # is equivalent to -with COGReader("my_cog.tif") as cog: - cog.tile(1, 1, 1, nodata=0) +with Reader("my_cog.tif") as src: + src.tile(1, 1, 1, nodata=0) ``` -## rio_tiler.io.STACReader +## rio_tiler.io.stac.STACReader In `rio-tiler` v2, we added a `rio_tiler.io.STACReader` to allow tile/metadata fetching of assets withing a STAC item. @@ -385,11 +392,12 @@ STACReader.__mro__ - **tms** (morecantile.TileMatrixSet, optional): morecantile TileMatrixSet used for tile reading (defaults to WebMercator) - **minzoom** (int, optional): dataset's minimum zoom level (for input tms) - **maxzoom** (int, optional): dataset's maximum zoom level (for input tms) +- **geographic_crs** (rasterio.crs.CRS, optional): CRS to use to calculate the geographic bounds (default to WGS84) - **include_assets** (set, optional): Set of assets to include from the `available` asset list - **exclude_assets** (set, optional): Set of assets to exclude from the `available` asset list - **include_asset_types** (set, optional): asset types to consider as valid type for the reader - **exclude_asset_types** (set, optional): asset types to consider as invalid type for the reader -- **reader** (BaseReader, optional): Reader to use to read assets (defaults to COGReader) +- **reader** (BaseReader, optional): Reader to use to read assets (defaults to rio_tiler.io.rasterio.Reader) - **reader_options** (dict, optional): Options to forward to the reader init - **fetch_options** (dict, optional): Options to pass to the `httpx.get` or `boto3` when fetching the STAC item @@ -430,7 +438,7 @@ EPSG:4326 #### Methods -The `STACReader` has the same methods as the `COGReader` (defined by the BaseReader/MultiBaseReader classes). +The `STACReader` has the same methods as the `Reader` (defined by the BaseReader/MultiBaseReader classes). !!! Important - Most of `STACReader` methods require to set either `assets=` or `expression=` option. @@ -461,7 +469,7 @@ print(img.assets) 'https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/34/S/GA/2020/3/S2A_34SGA_20200318_0_L2A/B02.tif', ] print(img.band_names) ->>> ['B01_1', 'B02_1'] +>>> ['B01_b1', 'B02_b1'] # Using `expression=` with STACReader(stac_url, exclude_assets={"thumbnail"}) as stac: @@ -470,26 +478,10 @@ with STACReader(stac_url, exclude_assets={"thumbnail"}) as stac: 103, 8, tilesize=256, - expression="B01/B02", + expression="B01_b1/B02_b1", ) assert img.count == 1 - -# Using `assets=` + `asset_expression` (apply band math in an asset) -with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: - img = stac.tile( - 145, - 103, - 8, - tilesize=256, - assets=["B01", "B02"], - asset_expression={ - "B01": "b1+500", # add 500 to the first band - "B02": "b1-100", # substract 100 to the first band - } - ) - assert img.count == 2 - # Using `assets=` + `asset_indexes` (select a specific index in an asset) with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: img = stac.tile( @@ -548,17 +540,17 @@ with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: assert img.count == 2 # each assets have one band ``` -- **point()**: Read the pixel values for assets for a given `lon, lat` coordinates. By default the coordinates are considered to be in WGS84. +- **point()**: Read the pixel values for assets for a given `lon, lat` coordinates. By default the coordinates are considered to be in WGS84. ```python with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: # stac.point(lon, lat, assets=?, expression=?, asset_expression=?, asset_indexes=?, **kwargs) data = stac.point(24.1, 31.9, assets=["B01", "B02"]) -print(data) +print(data.data) >>> [ - [3595], # values for B01 - [3198] # values for B02 + 3595, # values for B01 + 3198 # values for B02 ] ``` @@ -577,8 +569,8 @@ print(info["B01"].json(exclude_none=True)) "bounds": [23.106076243528157, 31.505173744374172, 24.296464503939948, 32.519334871696195], "minzoom": 8, "maxzoom": 11, - "band_metadata": [["1", {}]], - "band_descriptions": [["1", ""]], + "band_metadata": [["b1", {}]], + "band_descriptions": [["b1", ""]], "dtype": "uint16", "nodata_type": "Nodata", "colorinterp": ["gray"], @@ -603,9 +595,9 @@ print(list(stats)) >>> ["B01", "B02"] print(list(stats["B01"])) ->>> ["1"] # B01 has only one band entry "1" +>>> ["b1"] # B01 has only one band entry "1" -print(stats["B01"]["1"].json(exclude_none=True)) +print(stats["B01"]["b1"].json(exclude_none=True)) { "min": 283.0, "max": 7734.0, @@ -640,11 +632,11 @@ with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: # stats will be in form or {"band": BandStatistics(), ...} print(list(stats)) ->>> ["B01_1", "B02_1"] +>>> ["B01_b1", "B02_b1"] -assert isinstance(stats["B01_1"], BandStatistics) +assert isinstance(stats["B01_b1"], BandStatistics) -print(info["B01_1"].json(exclude_none=True)) +print(info["B01_b1"].json(exclude_none=True)) { "min": 283.0, "max": 7734.0, @@ -669,16 +661,511 @@ print(info["B01_1"].json(exclude_none=True)) with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: # stac.statistics(assets=?, asset_expression=?, asset_indexes=?, **kwargs) - stats = stac.merged_statistics(expression=["B01/B02"], max_size=128) + stats = stac.merged_statistics(expression=["B01_b1/B02_b1"], max_size=128) print(list(stats)) ->>> ["B01/B02"] +>>> ["B01_b1/B02_b1"] -assert isinstance(stats["B01/B02"], BandStatistics) +assert isinstance(stats["B01_b1/B02_b1"], BandStatistics) ``` ### STAC Expression -When using `expression`, the reader might consider assets as `1 band` data. Expression using multi bands are not supported (e.g: `asset1_b1 + asset2_b2`). +When using `expression`, the user will need to explicitly pass the band number to use within the asset e.g: `asset1_b1 + asset2_b2`. + + +## rio_tiler.io.rasterio.ImageReader + +The `Reader` is designed to work with simple raster datasets in their pixel coordinates. + +The class is derived from the `rio_tiler.io.rasterio.Reader` class. +```python +from rio_tiler.io import ImageReader + +ImageReader.__mro__ +>>> (rio_tiler.io.rasterio.ImageReader, + rio_tiler.io.rasterio.Reader, + rio_tiler.io.base.BaseReader, + rio_tiler.io.base.SpatialMixin, + object) +``` + +#### Attributes + +- **input** (str): filepath +- **dataset** (rasterio dataset, optional): rasterio opened dataset +- **colormap** (dict, optional): dataset's colormap +- **options** (rio_tiler.reader.Options, optional): Options to forward to rio_tiler.reader functions (e.g nodata, vrt_options, resampling) + +#### Properties + +- **bounds**: dataset's bounds (in dataset crs) +- **transform**: dataset Affine transform (in pixel coordinates) +- **minzoom**: dataset minzoom +- **maxzoom**: dataset maxzoom + +```python +from rio_tiler.io import ImageReader + +with ImageReader("image.jpg") as src: + print(src.dataset) + print(src.minzoom) + print(src.maxzoom) + print(src.transform) + print(src.bounds) + print(src.colormap) + +>> +0 +3 +Affine(1.0, 0.0, 0.0, 0.0, 1.0, 0.0) +(0, 2000, 2000, 0) +{} +``` + +#### Methods + +- **read()**: Read the entire dataset + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import ImageData + +with ImageReader("image.jpeg") as src: + img = src.read() + assert isinstance(img, ImageData) + assert not img.crs + assert img.assets == ["image.jpeg"] + assert img.width == src.dataset.width + assert img.height == src.dataset.height + assert img.count == src.dataset.count + +# With indexes +with ImageReader("image.jpeg") as src: + img = src.read(indexes=1) # or src.read(indexes=(1,)) + assert img.count == 1 + assert img.band_names == ["b1"] + +# With expression +with ImageReader("image.jpeg") as src: + img = src.read(expression="b1/b2") + assert img.count == 1 + assert img.band_names == ["b1/b2"] +``` + +- **tile()**: Read tile from the image + +For ImageReader we are using a custom `LocalTileMatrixSet` constructed from the dataset width and height. The origin is the Top-Left of the image. + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import ImageData + +with ImageReader("image.jpeg") as src: + # src.tile(tile_x, tile_y, tile_z, **kwargs) + img = src.tile(0, 0, src.maxzoom) + assert isinstance(img, ImageData) + assert not img.crs + assert img.bounds == (0, 256, 256, 0) + + img = src.tile(0, 0, src.minzoom) + assert isinstance(img, ImageData) + assert img.bounds[0] == 0 + assert img.bounds[3] == 0 + +# With indexes +with ImageReader("image.jpeg") as src: + img = src.tile(1, 2, 3, tilesize=256, indexes=1) + assert img.count == 1 + +# With expression +with ImageReader("image.jpeg") as src: + img = src.tile(1, 2, 3, tilesize=256, expression="B1/B2") + assert img.count == 1 +``` + +- **part()**: Read an image for a given bounding box (`bbox`). The origin is the Top-Left of the image. -If assets have difference number of bands and the `asset_indexes` is not specified the process will fail because it will try to apply an expression using arrays of different sizes. +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import ImageData + +with ImageReader("image.jpeg") as src: + # src.part((left, bottom, right, top), **kwargs) + img = src.part((0, 256, 256, 0)) # read the top-left 256x256 square of the image + assert isinstance(img, ImageData) + assert img.assets == ["myfile.tif"] + assert img.bounds == (0, 256, 256, 0) + +# Limit output size +with ImageReader("image.jpeg") as src: + img = src.part((0, 256, 256, 0), max_size=50) + +# With indexes +with ImageReader("image.jpeg") as src: + img = src.part((0, 256, 256, 0), indexes=1) + +# With expression +with ImageReader("image.jpeg") as src: + img = src.part((0, 256, 256, 0), expression="b1/b2") +``` + +- **feature()**: Read an image for a geojson feature. In the pixel coordinate system. + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import ImageData + +feat = { + "coordinates": [ + [ + [-100.0, -100.0], + [1000.0, 100.0], + [500.0, 1000.0], + [-50.0, 500.0], + [-100.0, -100.0], + ] + ], + "type": "Polygon", +} + +with ImageReader("image.jpeg") as src: + # src.part(geojson_feature, **kwargs) + img = src.feature(feat) + assert isinstance(img, ImageData) + assert img.assets == ["image.jpeg"] + assert img.bounds == (-100.0, 1000.0, 1000.0, -100.0) # bbox of the input feature + +# Limit output size +with ImageReader("image.jpeg") as src: + img = src.feature(feat, max_size=100) + +# Read high resolution +with ImageReader("image.jpeg") as src: + img = src.feature(feat, max_size=None) + +# With indexes +with ImageReader("image.jpeg") as src: + img = src.feature(feat, indexes=1) + +# With expression +with ImageReader("image.jpeg") as src: + img = src.feature(feat, expression="b1/b2") +``` + +- **preview()**: Read a preview of a raster + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import ImageData + +with ImageReader("image.jpeg") as src: + img = src.preview() + assert isinstance(img, ImageData) + +# With indexes +with ImageReader("image.jpeg") as src: + img = src.preview(indexes=1) + +# With expression +with ImageReader("image.jpeg") as src: + img = src.preview(expression="b1+2;b1*4") +``` + +- **point()**: Read the pixel values of a raster for a given `x, y` coordinates. The origin is the Top-Left of the image. + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import PointData + +with ImageReader("image.jpeg") as src: + # src.point(x, y) + pt = src.point(0, 0) # pixel at the origin + assert isinstance(pt, PointData) + +# With indexes +with ImageReader("image.jpeg") as src: + pt = src.point(0,0 , indexes=1) + print(pt.data) +>>> [1] + +# With expression +with ImageReader("image.jpeg") as src: + pt = src.point(0, 0, expression="b1+2;b1*4") + print(pt.data) +>>> [3, 4] +``` + +- **info()**: Return simple metadata about the dataset + +```python +from rio_tiler.io import ImageReader +from rio_tiler.models import Info + +with ImageReader("image.jpeg") as src: + info = src.info() + assert isinstance(info, Info) + +print(info.dict(exclude_none=True)) +>>> { + "bounds": [0, 4000, 4000, 0], + "minzoom": 0, + "maxzoom": 3, + "band_metadata": [["b1", {}]], + "band_descriptions": [["b1", ""]], + "dtype": "int8", + "colorinterp": ["palette"], + "nodata_type": "Nodata", + "colormap": { + "0": [0, 0, 0, 0], + "1": [0, 61, 0, 255], + ... + }, + "driver": "GTiff", + "count": 1, + "width": 4000, + "height": 4000, + "overviews": [2, 4, 8], +} +``` + +- **statistics()**: Return image statistics (Min/Max/Stdev) + +```python +from rio_tiler.io import ImageReader + +with ImageReader("image.jpeg") as src: + stats = src.statistics() + assert isinstance(stats, dict) + +# stats will be in form or {"band": BandStatistics(), ...} +print(stats) +>>> { + 'b1': BandStatistics(...), + 'b2': BandStatistics(...), + 'b3': BandStatistics(...) +} + +print(stats["b1"].dict()) +>>> { + "min": 1, + "max": 7872, + "mean": 2107.524612053134, + "count": 1045504, + "sum": 2203425412, + "std": 2271.0065537857326, + "median": 2800, + "majority": 1, + "minority": 7072, + "unique": 15, + "histogram": [ + [...], + [...] + ], + "valid_percent": 100, + "masked_pixels": 0, + "valid_pixels": 1045504, + "percentile_98": 6896, + "percentile_2": 1 +} +``` + + + + + + + +## rio_tiler.io.xarray.XarrayReader + +The `Reader` is designed to work with xarray.DataReader with full geo-reference metadata (CRS) and variables (X,Y) + +The class is derived from the `rio_tiler.io.base.BaseReader` class. +```python +from rio_tiler.io.xarray import XarrayReader + +XarrayReader.__mro__ +>>> (rio_tiler.io.xarray.XarrayReader, + rio_tiler.io.base.BaseReader, + rio_tiler.io.base.SpatialMixin, + object) +``` + +#### Attributes + +- **input** (xarray.DataArray): Xarray DataArray +- **tms** (morecantile.TileMatrixSet, optional): morecantile TileMatrixSet used for tile reading (defaults to WebMercator) +- **geographic_crs** (rasterio.crs.CRS, optional): CRS to use to calculate the geographic bounds (default to WGS84) + +#### Properties + +- **bounds**: dataset's bounds (in dataset crs) +- **crs**: dataset's crs +- **geographic_bounds**: dataset's bounds in WGS84 +- **minzoom**: dataset minzoom (in TMS) +- **maxzoom**: dataset maxzoom (in TMS) + + +```python +import numpy +import xarray +from datetime import datetime +from rio_tiler.io.xarray import XarrayReader + +arr = numpy.random.randn(1, 33, 35) +data = xarray.DataArray( + arr, + dims=("time", "y", "x"), + coords={ + "x": list(range(-170, 180, 10)), + "y": list(range(-80, 85, 5)), + "time": [datetime(2022, 1, 1)], + }, +) +data.attrs.update({"valid_min": arr.min(), "valid_max": arr.max()}) +data.rio.write_crs("epsg:4326", inplace=True) + +with XarrayReader(data) as src: + print(src.input) + print(src.tms.identifier) + print(src.minzoom) + print(src.maxzoom) + print(src.bounds) + print(src.crs) + print(src.geographic_bounds) + +>> +WebMercatorQuad +0 +0 +(-175.0, -82.5, 175.0, 82.5) +EPSG:4326 +(-175.0, -82.5, 175.0, 82.5) +``` + +#### Methods + +- **tile()**: Read map tile from a raster + +```python +from rio_tiler.contants import WEB_MERCATOR_CRS +from rio_tiler.io import XarrayReader +from rio_tiler.models import ImageData + +with XarrayReader(data) as src: + # src.tile(tile_x, tile_y, tile_z, tilesize, resampling_method) + img = src.tile(1, 2, 3) + assert isinstance(img, ImageData) + assert img.crs == WEB_MERCATOR_CRS +``` + +- **part()**: Read a DataArray for a given bounding box (`bbox`). By default the bbox is considered to be in WGS84. + +```python +from rio_tiler.io import XarrayReader +from rio_tiler.models import ImageData + +with XarrayReader(data) as src: + # src.part((minx, miny, maxx, maxy), dst_crs, bounds_crs, resampling_method) + img = src.part((10, 10, 20, 20)) + assert isinstance(img, ImageData) + assert img.crs == WGS84_CRS + assert img.bounds == (10, 10, 20, 20) + +# Pass bbox in WGS84 (default) but return data in the input dataset CRS +with XarrayReader(data) as src: + img = src.part((10, 10, 20, 20), dst_crs=src.dataset.crs) + assert img.crs == src.dataset.crs +``` + +- **feature()**: Read a DataArray for a geojson feature. By default the feature is considered to be in WGS84. + +```python +from rio_tiler.constants import WGS84_CRS +from rio_tiler.io import XarrayReader +from rio_tiler.models import ImageData + +feat = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-54.45, 73.05], + [-55.05, 72.79], + [-55.61, 72.46], + [-53.83, 72.36], + [-54.45, 73.05], + ] + ], + }, +} + +with XarrayReader(data) as src: + # src.part(geojson_feature, **kwargs) + img = src.feature(feat) + assert isinstance(img, ImageData) + assert img.crs == WGS84_CRS + assert img.bounds == (-55.61, 72.36, -53.83, 73.05) # bbox of the input feature + +# Pass bbox in WGS84 (default) but return data in the input dataset CRS +with XarrayReader(data) as src: + img = src.feature(feat, dst_crs=src.dataset.crs) + assert img.crs == src.dataset.crs +``` + +- **point()**: Read the pixel values of a DataArray for a given `lon, lat` coordinates. By default the coordinates are considered to be in WGS84. + +```python +from rio_tiler.io import XarrayReader +from rio_tiler.models import PointData + +with XarrayReader(data) as src: + # src.point(lon, lat, coord_crs) + pt = src.point(-100, 25) + assert isinstance(pt, PointData) +``` + +- **info()**: Return simple metadata about the DataArray + +```python +from rio_tiler.io import XarrayReader +from rio_tiler.models import Info + +with XarrayReader(data) as src: + info = src.info() + assert isinstance(info, Info) + +print(info.json(exclude_none=True)) +>>> { + "bounds": [-175.0, -82.5, 175.0, 82.5], + "minzoom": 0, + "maxzoom": 0, + "band_metadata": [["b1", {}]], + "band_descriptions": [["b1", "2022-01-01T00:00:00.000000000"]], + "dtype": "float64", + "nodata_type": "None", + "width": 35, + "attrs": { + "valid_min": -3.148671506292848, + "valid_max": 4.214148915352746 + }, + "count": 1, + "height": 33 +} +``` + +- **preview()**: + +!!! Important + + Not Implemented + + +- **statistics()**: + +!!! Important + + Not Implemented +``` diff --git a/docs/src/supported_format.md b/docs/src/supported_format.md index 1bd88d39..84d3442b 100644 --- a/docs/src/supported_format.md +++ b/docs/src/supported_format.md @@ -1,7 +1,7 @@ `rio-tiler` can work with all raster formats supported by [GDAL](https://gdal.org). That's being said, `rio-tiler` works better with data format that supports **partial reading**, like [Cloud Optimized GeoTIFF](http://cogeo.org). -On interesting feature of Cloud Optimized GeoTIFF is the internal overviews which enable fast preview of the data. For example, when using the `COGReader.preview` method, rio-tiler will only fetch the internal overviews instead of the whole data, to be able to construct the output array. Doing this reduce the amount of data transfer and thus increase the process speed. +On interesting feature of Cloud Optimized GeoTIFF is the internal overviews which enable fast preview of the data. For example, when using the `Reader.preview` method, rio-tiler will only fetch the internal overviews instead of the whole data, to be able to construct the output array. Doing this reduce the amount of data transfer and thus increase the process speed. ### VRT @@ -11,3 +11,14 @@ GDAL's [Virtual format](https://gdal.org/drivers/raster/vrt.html#raster-vrt) is Map Tile reading from VRT might not be efficient if overviews are not present, because GDAL will try to open a lot of files. ![](img/vrt_tile.png) + + +### Xarray + +!!! info "New in version 4.0" + +When `xarray` and `rioxarray` are installed in your environment, you can use `rio_tiler.io.XarrayReader` to read `xarray.DataArray` using the *usual* rio-tiler's Readers methods (`part()`, `tile()`, `feature()`). + +!!! warnings + - Datarray must be fully geo-referenced with a CRS and X,Y variables (longitude, latitude) + - Performance is largely dependant on the chunking of the array diff --git a/docs/src/v4_migration.md b/docs/src/v4_migration.md new file mode 100644 index 00000000..4e57670e --- /dev/null +++ b/docs/src/v4_migration.md @@ -0,0 +1,326 @@ + +# Breaking changes + +`rio-tiler` version 4.0 introduced [many breaking changes](release-notes.md). This +document aims to help with migrating your code to use `rio-tiler` 4.0. + +## Python >=3.8 + +As for rasterio, we removed python 3.7 support (https://github.com/rasterio/rasterio/issues/2445) + +## *COG*Reader -> **Reader** + +Because the main reader will not only work with COG but most of GDAL supported raster, we choose to rename it to `Reader`. + +```python +# before +from rio_tiler.io import COGReader +from rio_tiler.io.cogeo import COGReader + +# now +from rio_tiler.io import Reader +from rio_tiler.io.rasterio import Reader +``` + +Note: We created `rio_tiler.io.COGReader` alias to `Reader` for compatibility. + +## rio_tiler.io.cogeo -> rio_tiler.io.**rasterio** + +Reader's submodule now reflect the backend they use (rasterio, xarray, stac, ...) + +```python +# before +from rio_tiler.io.cogeo import COGReader + +# now +from rio_tiler.io.rasterio import Reader +``` + +## **Band names** + +Band names are now prefixed with `b` (e.g `b1`, `b2`) + +```python +# before +with COGReader( + "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" +) as src: + stats = src.statistics() + print(list(stats)) + >>> ["1", "2", "3"] + + info = src.info() + print(info.band_metadata) + >>> [("1", {}), ("2", {}), ("3", {})] + + print(info.band_descriptions) + >>> [("1", ""), ("2", ""), ("3", "")] + +# now +with Reader( + "http://oin-hotosm.s3.amazonaws.com/5a95f32c2553e6000ce5ad2e/0/10edab38-1bdd-4c06-b83d-6e10ac532b7d.tif" +) as src: + stats = src.statistics() + print(list(stats)) + >>> ["b1", "b2", "b3"] + + info = src.info() + print(info.band_metadata) + >>> [("b1", {}), ("b2", {}), ("b3", {})] + + print(info.band_descriptions) + >>> [("b1", ""), ("b2", ""), ("b3", "")] +``` + +## MultiBaseReader **Expressions** + +We updated the `expression` format for `MultiBaseReader` (e.g STAC) to include **band names** and not only the asset name + +```python +# before +with STACReader("stac.json") as stac: + stac.tile(701, 102, 8, expression="green/red") + +# now +with STACReader("stac.json") as stac: + stac.tile(701, 102, 8, expression="green_b1/red_b1") +``` + +In addition we also removed `asset_expression` option in `MultiBaseReader`. This can be achieved directly using expression. + +```python +# before +with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: + img = stac.tile( + 145, + 103, + 8, + tilesize=256, + assets=["B01", "B02"], + asset_expression={ + "B01": "b1+500", # add 500 to the first band + "B02": "b1-100", # substract 100 to the first band + } + ) + +# now +with STACReader(stac_url, exclude_assets={"thumbnail"},) as stac: + img = stac.tile( + 145, + 103, + 8, + tilesize=256, + expression="B01_b1+500;B02_b1-100", + ) +``` + +## No more GCPCOGReader + +`rio_tiler.io.Reader` will now recognize if the files has internal GCPS. + +```python +# before +from rio_tiler.io import GCPCOGReader + +with GCPCOGReader("my_tif_with_gcps.tif") as src: + pass + +# now +from rio_tiler.io import Reader + +with Reader("my_tif_with_gcps.tif") as src: + pass +``` + +## **PointData** object + +As for method returning `images`, methods returning point values (`Reader.point()`) now return a `PointData` object. + +```python +# before +with COGReader("cog.tif") as cog: + print(cog.point(10.20, -42.0)) + >>> [0, 0, 0] + +# now +with Reader("cog.tif") as cog: + print(cog.point(10.20, -42.0)) + >>> PointData( + data=array([3744], dtype=uint16), + mask=array([255], dtype=uint8), + band_names=['b1'], + coordinates=(10.20, -42), + crs=CRS.from_epsg(4326), + assets=['cog.tif'], + metadata={} + ) +``` + +## Low-level reader methods return ImageData and PointData objects + +`rio_tiler.reader.read` and `rio_tiler.readers.part` now return `ImageData` object instead of `Tuple[ndarray, ndarray]`. + +```python +from rio_tiler.reader import read, part, point +from rio_tiler.models import ImageData, PointData + +# before +with rasterio.open("image.tif") as src: + data, mask = read(src) + pts = point(10.20, -42.0) + print(pts) + >>> [0, 0, 0] + +# now +with rasterio.open("image.tif") as src: + img = read(src) + assert isinstance(img, ImageData) + + pts = point(src, (10.20, -42.0)) + assert isinstance(pts, PointData) + print(pts) + >>> PointData( + data=array([3744], dtype=uint16), + mask=array([255], dtype=uint8), + band_names=['b1'], + coordinates=(10.20, -42), + crs=CRS.from_epsg(4326), + assets=['cog.tif'], + metadata={} + ) +``` + +## **Reader** options + +We removed `nodata`, `unscale`, `resampling_method`, `vrt_options` and `post_process` options to `rio_tiler.io.Reader` init method and replaced with a global `options`: +```python +# before +with COGReader("cog.tif", nodata=1, resampling_method="bilinear") as cog: + data = cog.preview() + +# now +with Reader(COGEO, options={"nodata": 1, "resampling_method": "bilinear"}) as cog: + data = cog.preview() +``` + +## Base classes **minzoom** and **maxzoom** + +We moved min/max zoom attribute from the `SpatialMixin` to the base classes definition directly. This means that each class should now take care of the definition of those two variables. + +```python +# before +@attr.s +class BandFileReader(MultiBandReader): + """Test MultiBand""" + + input: str = attr.ib() + tms: morecantile.TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) + + reader: Type[BaseReader] = attr.ib(init=False, default=Reader) + reader_options: Dict = attr.ib(factory=dict) + + def __attrs_post_init__(self): + ... + +# now +@attr.s +class BandFileReader(MultiBandReader): + """Test MultiBand""" + + input: str = attr.ib() + tms: morecantile.TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) + + reader: Type[BaseReader] = attr.ib(init=False, default=Reader) + reader_options: Dict = attr.ib(factory=dict) + + minzoom: int = attr.ib() + maxzoom: int = attr.ib() + + @minzoom.default + def _minzoom(self): + return self.tms.minzoom + + @maxzoom.default + def _maxzoom(self): + return self.tms.maxzoom + + def __attrs_post_init__(self): + ... +``` + +# New Features + +## Non-Geo reader + +Because not all raster are geo-referenced, we added `rio_tiler.io.ImageReader` to allow opening and reading non-geo images. All methods are returning data in the pixel coordinate system. + +```python +with ImageReader("image.jpg") as src: + info = src.info() + + stats = src.statistics() + + # Part of the image (Origin is top-lef, coordinates should be in form of (left, bottom, right, top)) + im = src.part((0, 100, 100, 0)) + + # 256x256 Tile (Origin of the TMS is top-lef) + im = src.tile(0, 0, src.maxzoom) + + # read pixel x=10, y=5 (Origin is top-left) + pt = src.point(10, 5) +``` + +## Xarray reader + +We added an *optional* xarray compatible reader in rio-tiler v4.0. The reader takes a xarray.DataArray as input which should have a CRS and geo-spatial variables (x,y or longitude,latitude). + +```python +import rioxarray +import xarray +from rio_tiler.io import XarrayReader + +with xarray.open_dataset( + "https://ncsa.osn.xsede.org/Pangeo/pangeo-forge/noaa-coastwatch-geopolar-sst-feedstock/noaa-coastwatch-geopolar-sst.zarr", + engine="zarr", + decode_coords="all" +) as src: + ds = src["analysed_sst"][:1] + # the SST dataset do not have a CRS info + # so we need to add it to `virtualy` within the Xarray DataArray + ds.rio.write_crs("epsg:4326", inplace=True) + + with XarrayReader(ds) as dst: + print(dst.info()) + img = dst.tile(1, 1, 2) +``` + +Note: Users might experience some really bad performance depending on the chunking of the original zarr. + +## Dataset Statistics + +Starting with rio-tiler 4.0, if the input dataset has [`statistics`](https://gdal.org/user/raster_data_model.html#raster-band) (e.g `STATISTICS_MINIMUM`, `STATISTICS_MAXIMUM`) within its metadata, rio-tiler will try to use it to rescale automatically the output image. + +```python +from rio_tiler.io import Reader + +with Reader("https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif") as src: + info = src.info() + print(info.band_metadata) + >>> [('b1', + {'STATISTICS_COVARIANCES': '10685.98787505646', + 'STATISTICS_EXCLUDEDVALUES': '-9999', + 'STATISTICS_MAXIMUM': '2015.0944824219', + 'STATISTICS_MEAN': '1754.471184271', + 'STATISTICS_MINIMUM': '1615.8128662109', + 'STATISTICS_SKIPFACTORX': '1', + 'STATISTICS_SKIPFACTORY': '1', + 'STATISTICS_STDDEV': '103.37305197708'})] + + img = src.preview() + # The min/max statistics are saved within every output image object + print(img.dataset_statistics) + >>> [(1615.8128662109, 2015.0944824219)] + + buffer = img.render() + >>> rio-tiler/rio_tiler/models.py:516: InvalidDatatypeWarning: Invalid type: `float32` for the `PNG` driver. Data will be rescaled using min/max type bounds or dataset_statistics. +``` diff --git a/pyproject.toml b/pyproject.toml index 66740332..1522029e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rio-tiler" description = "User friendly Rasterio plugin to read raster datasets." readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {file = "LICENSE"} authors = [ {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, @@ -12,9 +12,9 @@ classifiers = [ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering :: GIS", ] dynamic = ["version"] @@ -28,7 +28,7 @@ dependencies = [ "morecantile>=3.1,<4.0", "pydantic", "pystac>=0.5.4", - "rasterio>=1.1.7", + "rasterio>=1.3.0", "rio-color", "importlib_resources>=1.1.0; python_version < '3.9'", ] @@ -39,10 +39,18 @@ test = [ "pytest-asyncio", "pytest-benchmark", "pytest-cov", + + # XarrayReader + "xarray", + "rioxarray", ] dev = [ "pre-commit", ] +xarray = [ + "xarray", + "rioxarray", +] docs = [ "nbconvert", "mkdocs", diff --git a/rio_tiler/colormap.py b/rio_tiler/colormap.py index 0d3900f6..4e6afc5e 100644 --- a/rio_tiler/colormap.py +++ b/rio_tiler/colormap.py @@ -8,13 +8,18 @@ import attr import numpy -from .errors import ( +from rio_tiler.errors import ( ColorMapAlreadyRegistered, InvalidColorFormat, InvalidColorMapName, InvalidFormat, ) -from .types import ColorMapType, DataMaskType, GDALColorMapType, IntervalColorMapType +from rio_tiler.types import ( + ColorMapType, + DataMaskType, + GDALColorMapType, + IntervalColorMapType, +) try: from importlib.resources import files as resources_files # type: ignore diff --git a/rio_tiler/constants.py b/rio_tiler/constants.py index 78cebc3e..e96430a9 100644 --- a/rio_tiler/constants.py +++ b/rio_tiler/constants.py @@ -6,7 +6,7 @@ import morecantile from rasterio.crs import CRS -from .types import BBox, ColorTuple, Indexes, NoData, NumType # noqa +from rio_tiler.types import BBox, ColorTuple, Indexes, NoData, NumType # noqa MAX_THREADS = int( os.environ.get("RIO_TILER_MAX_THREADS", multiprocessing.cpu_count() * 5) diff --git a/rio_tiler/errors.py b/rio_tiler/errors.py index f51bb027..326a66b2 100644 --- a/rio_tiler/errors.py +++ b/rio_tiler/errors.py @@ -13,8 +13,8 @@ class TileOutsideBounds(RioTilerError): """Z-X-Y Tile is outside image bounds.""" -class IncorrectTileBuffer(RioTilerError): - """Tile buffer is a float but not half of an integer""" +class InvalidBufferSize(RioTilerError): + "`buffer` must be a multiple of `0.5` (e.g: 0.5, 1, 1.5, ...)." class PointOutsideBounds(RioTilerError): diff --git a/rio_tiler/expression.py b/rio_tiler/expression.py index 7b33c868..d733be08 100644 --- a/rio_tiler/expression.py +++ b/rio_tiler/expression.py @@ -2,7 +2,7 @@ import re import warnings -from typing import List, Sequence, Tuple, Union +from typing import List, Sequence, Tuple import numexpr import numpy @@ -59,7 +59,7 @@ def get_expression_blocks(expression: str) -> List[str]: def apply_expression( blocks: Sequence[str], - bands: Sequence[Union[str, int]], + bands: Sequence[str], data: numpy.ndarray, ) -> numpy.ndarray: """Apply rio-tiler expression. @@ -74,6 +74,11 @@ def apply_expression( numpy.array: output data. """ + if len(bands) != data.shape[0]: + raise ValueError( + f"Incompatible number of bands ({bands}) and data shape {data.shape}" + ) + return numpy.array( [ numpy.nan_to_num( diff --git a/rio_tiler/io/__init__.py b/rio_tiler/io/__init__.py index 2ccea3ad..43b6c801 100644 --- a/rio_tiler/io/__init__.py +++ b/rio_tiler/io/__init__.py @@ -1,5 +1,9 @@ """rio-tiler.io""" -from .base import AsyncBaseReader, BaseReader, MultiBandReader, MultiBaseReader # noqa -from .cogeo import COGReader, GCPCOGReader # noqa +from .base import BaseReader, MultiBandReader, MultiBaseReader # noqa +from .rasterio import ImageReader, Reader # noqa from .stac import STACReader # noqa +from .xarray import XarrayReader # noqa + +# Keep Compatibility with <4.0 +COGReader = Reader diff --git a/rio_tiler/io/base.py b/rio_tiler/io/base.py index 37fdf2f6..966322fb 100644 --- a/rio_tiler/io/base.py +++ b/rio_tiler/io/base.py @@ -3,7 +3,7 @@ import abc import re import warnings -from typing import Any, Coroutine, Dict, List, Optional, Sequence, Tuple, Type, Union +from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union import attr import numpy @@ -11,18 +11,24 @@ from rasterio.crs import CRS from rasterio.warp import transform_bounds -from ..constants import WEB_MERCATOR_TMS, WGS84_CRS -from ..errors import ( +from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS +from rio_tiler.errors import ( ExpressionMixingWarning, MissingAssets, MissingBands, TileOutsideBounds, ) -from ..expression import apply_expression, get_expression_blocks -from ..models import BandStatistics, ImageData, Info -from ..tasks import multi_arrays, multi_values -from ..types import BBox, Indexes -from ..utils import get_array_statistics +from rio_tiler.models import BandStatistics, ImageData, Info, PointData +from rio_tiler.tasks import multi_arrays, multi_points, multi_values +from rio_tiler.types import BBox, Indexes +from rio_tiler.utils import get_array_statistics, normalize_bounds + + +def _AssetExpressionWarning(): + warnings.warn( + "asset_expression is deprecated and will be removed in 4.0. Use pure Expression", + DeprecationWarning, + ) @attr.s @@ -31,19 +37,11 @@ class SpatialMixin: Attributes: tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. - minzoom (int): Dataset Min Zoom level. **Not in __init__**. - maxzoom (int): Dataset Max Zoom level. **Not in __init__**. - bounds (tuple): Dataset bounds (left, bottom, right, top). **Not in __init__**. - crs (rasterio.crs.CRS): Dataset crs. **Not in __init__**. - geographic_crs (rasterio.crs.CRS): CRS to use as geographic coordinate system. Defaults to WGS84. **Not in __init__**. """ tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - minzoom: int = attr.ib(init=False) - maxzoom: int = attr.ib(init=False) - bounds: BBox = attr.ib(init=False) crs: CRS = attr.ib(init=False) @@ -51,7 +49,7 @@ class SpatialMixin: @property def geographic_bounds(self) -> BBox: - """return bounds in WGS84.""" + """Return dataset bounds in geographic_crs.""" if self.crs == self.geographic_crs: return self.bounds @@ -64,7 +62,7 @@ def geographic_bounds(self) -> BBox: ) except: # noqa warnings.warn( - "Cannot dertermine bounds in geographic CRS, will default to (-180.0, -90.0, 180.0, 90.0).", + "Cannot determine bounds in geographic CRS, will default to (-180.0, -90.0, 180.0, 90.0).", UserWarning, ) bounds = (-180.0, -90, 180.0, 90) @@ -117,17 +115,26 @@ def tile_exists(self, tile_x: int, tile_y: int, tile_z: int) -> bool: if not all(numpy.isfinite(tile_bounds)): return True + tile_bounds = normalize_bounds(tile_bounds) + dst_bounds = normalize_bounds(self.bounds) + return ( - (tile_bounds[0] < self.bounds[2]) - and (tile_bounds[2] > self.bounds[0]) - and (tile_bounds[3] > self.bounds[1]) - and (tile_bounds[1] < self.bounds[3]) + (tile_bounds[0] < dst_bounds[2]) + and (tile_bounds[2] > dst_bounds[0]) + and (tile_bounds[3] > dst_bounds[1]) + and (tile_bounds[1] < dst_bounds[3]) ) @attr.s class BaseReader(SpatialMixin, metaclass=abc.ABCMeta): - """Rio-tiler.io BaseReader.""" + """Rio-tiler.io BaseReader. + + Attributes: + input (any): Reader's input. + tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. + + """ input: Any = attr.ib() tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) @@ -151,111 +158,7 @@ def info(self) -> Info: ... @abc.abstractmethod - def statistics(self, **kwargs: Any) -> Dict[str, BandStatistics]: - """Return bands statistics from a dataset. - - Returns: - Dict[str, rio_tiler.models.BandStatistics]: bands statistics. - - """ - ... - - @abc.abstractmethod - def tile(self, tile_x: int, tile_y: int, tile_z: int, **kwargs: Any) -> ImageData: - """Read a Map tile from the Dataset. - - Args: - tile_x (int): Tile's horizontal index. - tile_y (int): Tile's vertical index. - tile_z (int): Tile's zoom level index. - - Returns: - rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. - - """ - ... - - @abc.abstractmethod - def part(self, bbox: BBox, **kwargs: Any) -> ImageData: - """Read a Part of a Dataset. - - Args: - bbox (tuple): Output bounds (left, bottom, right, top) in target crs. - - Returns: - rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. - - """ - ... - - @abc.abstractmethod - def preview(self, **kwargs: Any) -> ImageData: - """Read a preview of a Dataset. - - Returns: - rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. - - """ - ... - - @abc.abstractmethod - def point(self, lon: float, lat: float, **kwargs: Any) -> List: - """Read a value from a Dataset. - - Args: - lon (float): Longitude. - lat (float): Latitude. - - Returns: - list: Pixel value per bands/assets. - - """ - ... - - @abc.abstractmethod - def feature(self, shape: Dict, **kwargs: Any) -> ImageData: - """Read a Dataset for a GeoJSON feature. - - Args: - shape (dict): Valid GeoJSON feature. - - Returns: - rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. - - """ - ... - - -@attr.s -class AsyncBaseReader(SpatialMixin, metaclass=abc.ABCMeta): - """Rio-tiler.io AsyncBaseReader.""" - - input: Any = attr.ib() - tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - - async def __aenter__(self): - """Support using with Context Managers.""" - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - """Support using with Context Managers.""" - pass - - @abc.abstractmethod - async def info(self) -> Coroutine[Any, Any, Info]: - """Return Dataset's info. - - Returns: - rio_tile.models.Info: Dataset info. - - """ - ... - - @abc.abstractmethod - async def statistics( - self, - **kwargs: Any, - ) -> Coroutine[Any, Any, Dict[str, BandStatistics]]: + def statistics(self) -> Dict[str, BandStatistics]: """Return bands statistics from a dataset. Returns: @@ -265,9 +168,7 @@ async def statistics( ... @abc.abstractmethod - async def tile( - self, tile_x: int, tile_y: int, tile_z: int, **kwargs: Any - ) -> Coroutine[Any, Any, ImageData]: + def tile(self, tile_x: int, tile_y: int, tile_z: int) -> ImageData: """Read a Map tile from the Dataset. Args: @@ -282,7 +183,7 @@ async def tile( ... @abc.abstractmethod - async def part(self, bbox: BBox, **kwargs: Any) -> Coroutine[Any, Any, ImageData]: + def part(self, bbox: BBox) -> ImageData: """Read a Part of a Dataset. Args: @@ -295,7 +196,7 @@ async def part(self, bbox: BBox, **kwargs: Any) -> Coroutine[Any, Any, ImageData ... @abc.abstractmethod - async def preview(self, **kwargs: Any) -> Coroutine[Any, Any, ImageData]: + def preview(self) -> ImageData: """Read a preview of a Dataset. Returns: @@ -305,9 +206,7 @@ async def preview(self, **kwargs: Any) -> Coroutine[Any, Any, ImageData]: ... @abc.abstractmethod - async def point( - self, lon: float, lat: float, **kwargs: Any - ) -> Coroutine[Any, Any, List]: + def point(self, lon: float, lat: float) -> PointData: """Read a value from a Dataset. Args: @@ -315,15 +214,13 @@ async def point( lat (float): Latitude. Returns: - list: Pixel value per bands/assets. + rio_tiler.models.PointData: PointData instance with data, mask and spatial info. """ ... @abc.abstractmethod - async def feature( - self, shape: Dict, **kwargs: Any - ) -> Coroutine[Any, Any, ImageData]: + def feature(self, shape: Dict) -> ImageData: """Read a Dataset for a GeoJSON feature. Args: @@ -345,17 +242,21 @@ class MultiBaseReader(SpatialMixin, metaclass=abc.ABCMeta): Attributes: input (any): input data. tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. + minzoom (int, optional): Set dataset's minzoom. + maxzoom (int, optional): Set dataset's maxzoom. reader_options (dict, option): options to forward to the reader. Defaults to `{}`. - reader (rio_tiler.io.BaseReader): reader. **Not in __init__**. - assets (sequence): Asset list. **Not in __init__**. """ input: Any = attr.ib() tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - reader_options: Dict = attr.ib(factory=dict) + + minzoom: int = attr.ib(default=None) + maxzoom: int = attr.ib(default=None) reader: Type[BaseReader] = attr.ib(init=False) + reader_options: Dict = attr.ib(factory=dict) + assets: Sequence[str] = attr.ib(init=False) def __enter__(self): @@ -373,11 +274,11 @@ def _get_asset_url(self, asset: str) -> str: def parse_expression(self, expression: str) -> Tuple: """Parse rio-tiler band math expression.""" - assets = "|".join([rf"\b{asset}\b" for asset in self.assets]) - _re = re.compile(assets.replace("\\\\", "\\")) + assets = "|".join(self.assets) + _re = re.compile(rf"\b({assets})_b\d+\b") return tuple(set(re.findall(_re, expression))) - def info( # type: ignore + def info( self, assets: Union[Sequence[str], str] = None, **kwargs: Any ) -> Dict[str, Info]: """Return metadata from multiple assets. @@ -407,7 +308,7 @@ def _reader(asset: str, **kwargs: Any) -> Dict: return multi_values(assets, _reader, **kwargs) - def statistics( # type: ignore + def statistics( self, assets: Union[Sequence[str], str] = None, asset_indexes: Optional[Dict[str, Indexes]] = None, # Indexes for each asset @@ -452,7 +353,7 @@ def _reader(asset: str, *args, **kwargs) -> Dict: return multi_values(assets, _reader, **kwargs) - def merged_statistics( # type: ignore + def merged_statistics( self, assets: Union[Sequence[str], str] = None, expression: Optional[str] = None, @@ -471,7 +372,7 @@ def merged_statistics( # type: ignore assets (sequence of str or str): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** categorical (bool): treat input data as categorical data. Defaults to False. categories (list of numbers, optional): list of categories to return value for. percentiles (list of numbers, optional): list of percentile values to calculate. Defaults to `[2, 98]`. @@ -484,6 +385,9 @@ def merged_statistics( # type: ignore Dict[str, rio_tiler.models.BandStatistics]: bands statistics. """ + if asset_expression: + _AssetExpressionWarning() + if not expression: if not assets: warnings.warn( @@ -496,7 +400,6 @@ def merged_statistics( # type: ignore assets=assets, expression=expression, asset_indexes=asset_indexes, - asset_expression=asset_expression, max_size=max_size, **kwargs, ) @@ -536,13 +439,16 @@ def tile( assets (sequence of str or str, optional): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** kwargs (optional): Options to forward to the `self.reader.tile` method. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. """ + if asset_expression: + _AssetExpressionWarning() + if not self.tile_exists(tile_x, tile_y, tile_z): raise TileOutsideBounds( f"Tile {tile_z}/{tile_x}/{tile_y} is outside image bounds" @@ -566,35 +472,20 @@ def tile( ) asset_indexes = asset_indexes or {} - asset_expression = asset_expression or {} def _reader(asset: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_asset_url(asset) + idx = asset_indexes.get(asset) or kwargs.pop("indexes", None) # type: ignore with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - data = cog.tile( - *args, - indexes=asset_indexes.get(asset, kwargs.pop("indexes", None)), # type: ignore - expression=asset_expression.get(asset), # type: ignore - **kwargs, - ) + data = cog.tile(*args, indexes=idx, **kwargs) data.band_names = [f"{asset}_{n}" for n in data.band_names] return data - output = multi_arrays( - assets, - _reader, - tile_x, - tile_y, - tile_z, - **kwargs, - ) - + img = multi_arrays(assets, _reader, tile_x, tile_y, tile_z, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, assets, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def part( self, @@ -612,13 +503,16 @@ def part( assets (sequence of str or str, optional): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** kwargs (optional): Options to forward to the `self.reader.part` method. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. """ + if asset_expression: + _AssetExpressionWarning() + if isinstance(assets, str): assets = (assets,) @@ -637,28 +531,20 @@ def part( ) asset_indexes = asset_indexes or {} - asset_expression = asset_expression or {} def _reader(asset: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_asset_url(asset) + idx = asset_indexes.get(asset) or kwargs.pop("indexes", None) # type: ignore with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - data = cog.part( - *args, - indexes=asset_indexes.get(asset, kwargs.pop("indexes", None)), # type: ignore - expression=asset_expression.get(asset), # type: ignore - **kwargs, - ) + data = cog.part(*args, indexes=idx, **kwargs) data.band_names = [f"{asset}_{n}" for n in data.band_names] return data - output = multi_arrays(assets, _reader, bbox, **kwargs) - + img = multi_arrays(assets, _reader, bbox, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, assets, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def preview( self, @@ -674,13 +560,16 @@ def preview( assets (sequence of str or str, optional): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** kwargs (optional): Options to forward to the `self.reader.preview` method. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. """ + if asset_expression: + _AssetExpressionWarning() + if isinstance(assets, str): assets = (assets,) @@ -699,27 +588,20 @@ def preview( ) asset_indexes = asset_indexes or {} - asset_expression = asset_expression or {} def _reader(asset: str, **kwargs: Any) -> ImageData: url = self._get_asset_url(asset) + idx = asset_indexes.get(asset) or kwargs.pop("indexes", None) # type: ignore with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - data = cog.preview( - indexes=asset_indexes.get(asset, kwargs.pop("indexes", None)), # type: ignore - expression=asset_expression.get(asset), # type: ignore - **kwargs, - ) + data = cog.preview(indexes=idx, **kwargs) data.band_names = [f"{asset}_{n}" for n in data.band_names] return data - output = multi_arrays(assets, _reader, **kwargs) - + img = multi_arrays(assets, _reader, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, assets, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def point( self, @@ -730,7 +612,7 @@ def point( asset_indexes: Optional[Dict[str, Indexes]] = None, # Indexes for each asset asset_expression: Optional[Dict[str, str]] = None, # Expression for each asset **kwargs: Any, - ) -> List: + ) -> PointData: """Read pixel value from multiple assets. Args: @@ -739,13 +621,16 @@ def point( assets (sequence of str or str, optional): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** kwargs (optional): Options to forward to the `self.reader.point` method. Returns: - list: Pixel values per assets. + PointData """ + if asset_expression: + _AssetExpressionWarning() + if isinstance(assets, str): assets = (assets,) @@ -764,26 +649,20 @@ def point( ) asset_indexes = asset_indexes or {} - asset_expression = asset_expression or {} - def _reader(asset: str, *args, **kwargs: Any) -> Dict: + def _reader(asset: str, *args, **kwargs: Any) -> PointData: url = self._get_asset_url(asset) + idx = asset_indexes.get(asset) or kwargs.pop("indexes", None) # type: ignore with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - return cog.point( - *args, - indexes=asset_indexes.get(asset, kwargs.pop("indexes", None)), # type: ignore - expression=asset_expression.get(asset), # type: ignore - **kwargs, - ) - - data = multi_values(assets, _reader, lon, lat, **kwargs) + data = cog.point(*args, indexes=idx, **kwargs) + data.band_names = [f"{asset}_{n}" for n in data.band_names] + return data - values = [numpy.array(d) for _, d in data.items()] + data = multi_points(assets, _reader, lon, lat, **kwargs) if expression: - blocks = get_expression_blocks(expression) - values = apply_expression(blocks, assets, values) + return data.apply_expression(expression) - return [v.tolist() for v in values] + return data def feature( self, @@ -801,13 +680,16 @@ def feature( assets (sequence of str or str, optional): assets to fetch info from. expression (str, optional): rio-tiler expression for the asset list (e.g. asset1/asset2+asset3). asset_indexes (dict, optional): Band indexes for each asset (e.g {"asset1": 1, "asset2": (1, 2,)}). - asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). + asset_expression (dict, optional): rio-tiler expression for each asset (e.g. {"asset1": "b1/b2+b3", "asset2": ...}). **Deprecated** kwargs (optional): Options to forward to the `self.reader.feature` method. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. """ + if asset_expression: + _AssetExpressionWarning() + if isinstance(assets, str): assets = (assets,) @@ -826,28 +708,20 @@ def feature( ) asset_indexes = asset_indexes or {} - asset_expression = asset_expression or {} def _reader(asset: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_asset_url(asset) + idx = asset_indexes.get(asset) or kwargs.pop("indexes", None) # type: ignore with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - data = cog.feature( - *args, - indexes=asset_indexes.get(asset, kwargs.pop("indexes", None)), # type: ignore - expression=asset_expression.get(asset), # type: ignore - **kwargs, - ) + data = cog.feature(*args, indexes=idx, **kwargs) data.band_names = [f"{asset}_{n}" for n in data.band_names] return data - output = multi_arrays(assets, _reader, shape, **kwargs) - + img = multi_arrays(assets, _reader, shape, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, assets, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img @attr.s @@ -859,17 +733,21 @@ class MultiBandReader(SpatialMixin, metaclass=abc.ABCMeta): Attributes: input (any): input data. tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. + minzoom (int, optional): Set dataset's minzoom. + maxzoom (int, optional): Set dataset's maxzoom. reader_options (dict, option): options to forward to the reader. Defaults to `{}`. - reader (rio_tiler.io.BaseReader): reader. **Not in __init__**. - bands (sequence): Band list. **Not in __init__**. """ input: Any = attr.ib() tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - reader_options: Dict = attr.ib(factory=dict) + + minzoom: int = attr.ib(default=None) + maxzoom: int = attr.ib(default=None) reader: Type[BaseReader] = attr.ib(init=False) + reader_options: Dict = attr.ib(factory=dict) + bands: Sequence[str] = attr.ib(init=False) def __enter__(self): @@ -1049,17 +927,15 @@ def _reader(band: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_band_url(band) with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore data = cog.tile(*args, **kwargs) - data.band_names = [band] + data.band_names = [band] # use `band` as name instead of band index return data - output = multi_arrays(bands, _reader, tile_x, tile_y, tile_z, **kwargs) + img = multi_arrays(bands, _reader, tile_x, tile_y, tile_z, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, bands, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def part( self, @@ -1101,17 +977,15 @@ def _reader(band: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_band_url(band) with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore data = cog.part(*args, **kwargs) - data.band_names = [band] + data.band_names = [band] # use `band` as name instead of band index return data - output = multi_arrays(bands, _reader, bbox, **kwargs) + img = multi_arrays(bands, _reader, bbox, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, bands, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def preview( self, @@ -1151,17 +1025,15 @@ def _reader(band: str, **kwargs: Any) -> ImageData: url = self._get_band_url(band) with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore data = cog.preview(**kwargs) - data.band_names = [band] + data.band_names = [band] # use `band` as name instead of band index return data - output = multi_arrays(bands, _reader, **kwargs) + img = multi_arrays(bands, _reader, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, bands, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img def point( self, @@ -1170,7 +1042,7 @@ def point( bands: Union[Sequence[str], str] = None, expression: Optional[str] = None, **kwargs: Any, - ) -> List: + ) -> PointData: """Read a pixel values from multiple bands. Args: @@ -1181,7 +1053,7 @@ def point( kwargs (optional): Options to forward to the `self.reader.point` method. Returns: - list: Pixel value per bands. + PointData """ if isinstance(bands, str): @@ -1201,19 +1073,18 @@ def point( "bands must be passed either via expression or bands options." ) - def _reader(band: str, *args, **kwargs: Any) -> Dict: + def _reader(band: str, *args, **kwargs: Any) -> PointData: url = self._get_band_url(band) with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore - return cog.point(*args, **kwargs)[0] # We only return the first value - - data = multi_values(bands, _reader, lon, lat, **kwargs) + data = cog.point(*args, **kwargs) + data.band_names = [band] # use `band` as name instead of band index + return data - values = [numpy.array(d) for _, d in data.items()] + data = multi_points(bands, _reader, lon, lat, **kwargs) if expression: - blocks = get_expression_blocks(expression) - values = apply_expression(blocks, bands, values) + return data.apply_expression(expression) - return [v.tolist() for v in values] + return data def feature( self, @@ -1255,14 +1126,12 @@ def _reader(band: str, *args: Any, **kwargs: Any) -> ImageData: url = self._get_band_url(band) with self.reader(url, tms=self.tms, **self.reader_options) as cog: # type: ignore data = cog.feature(*args, **kwargs) - data.band_names = [band] + data.band_names = [band] # use `band` as name instead of band index return data - output = multi_arrays(bands, _reader, shape, **kwargs) + img = multi_arrays(bands, _reader, shape, **kwargs) if expression: - blocks = get_expression_blocks(expression) - output.data = apply_expression(blocks, bands, output.data) - output.band_names = blocks + return img.apply_expression(expression) - return output + return img diff --git a/rio_tiler/io/cogeo.py b/rio_tiler/io/rasterio.py similarity index 51% rename from rio_tiler/io/cogeo.py rename to rio_tiler/io/rasterio.py index 6bd9aa07..214ff31e 100644 --- a/rio_tiler/io/cogeo.py +++ b/rio_tiler/io/rasterio.py @@ -1,79 +1,76 @@ -"""rio_tiler.io.cogeo: raster processing.""" +"""rio_tiler.io.rasterio: rio-tiler reader built on top Rasterio""" import contextlib import warnings -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Union import attr import numpy import rasterio -from morecantile import BoundingBox, Tile, TileMatrixSet +from affine import Affine +from morecantile import BoundingBox, Coords, Tile, TileMatrixSet +from morecantile.utils import _parse_tile_arg from rasterio import transform from rasterio.crs import CRS from rasterio.enums import Resampling from rasterio.features import bounds as featureBounds +from rasterio.features import geometry_mask from rasterio.io import DatasetReader, DatasetWriter, MemoryFile from rasterio.rio.overview import get_maximum_overview_level +from rasterio.transform import from_bounds as transform_from_bounds from rasterio.vrt import WarpedVRT -from rasterio.warp import calculate_default_transform, transform_bounds +from rasterio.warp import calculate_default_transform +from rasterio.windows import Window +from rasterio.windows import from_bounds as window_from_bounds -from .. import reader -from ..constants import WEB_MERCATOR_TMS, WGS84_CRS -from ..errors import ( +from rio_tiler import reader +from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS +from rio_tiler.errors import ( ExpressionMixingWarning, - IncorrectTileBuffer, NoOverviewWarning, + PointOutsideBounds, TileOutsideBounds, ) -from ..expression import apply_expression, get_expression_blocks, parse_expression -from ..models import BandStatistics, ImageData, Info -from ..types import BBox, DataMaskType, Indexes, NoData, NumType -from ..utils import ( +from rio_tiler.expression import parse_expression +from rio_tiler.io.base import BaseReader +from rio_tiler.models import BandStatistics, ImageData, Info, PointData +from rio_tiler.types import BBox, DataMaskType, Indexes, NumType +from rio_tiler.utils import ( create_cutline, get_array_statistics, - get_bands_names, has_alpha_band, has_mask_band, ) -from .base import BaseReader @attr.s -class COGReader(BaseReader): - """Cloud Optimized GeoTIFF Reader. +class Reader(BaseReader): + """Rasterio Reader. Attributes: - input (str): Cloud Optimized GeoTIFF path. + input (str): dataset path. dataset (rasterio.io.DatasetReader or rasterio.io.DatasetWriter or rasterio.vrt.WarpedVRT, optional): Rasterio dataset. - bounds (tuple): Dataset bounds (left, bottom, right, top). - crs (rasterio.crs.CRS): Dataset CRS. tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. - minzoom (int, optional): Set minzoom for the tiles. - maxzoom (int, optional): Set maxzoom for the tiles. geographic_crs (rasterio.crs.CRS, optional): CRS to use as geographic coordinate system. Defaults to WGS84. colormap (dict, optional): Overwrite internal colormap. - nodata (int or float or str, optional): Global options, overwrite internal nodata value. - unscale (bool, optional): Global options, apply internal scale and offset on all read operations. - resampling_method (rasterio.enums.Resampling, optional): Global options, resampling method to use for read operations. - vrt_options (dict, optional): Global options, WarpedVRT options to use for read operations. - post_process (callable, optional): Global options, Function to apply after all read operations. + options (dict, optional): Options to forward to low-level reader methods. Examples: - >>> with COGReader(src_path) as cog: - cog.tile(...) + >>> with Reader(src_path) as src: + src.tile(...) >>> # Set global options - with COGReader(src_path, unscale=True, nodata=0) as cog: - cog.tile(...) + with Reader(src_path, options={"unscale": True, "nodata": 0}) as src: + src.tile(...) >>> with rasterio.open(src_path) as src_dst: with WarpedVRT(src_dst, ...) as vrt_dst: - with COGReader(None, dataset=vrt_dst) as cog: - cog.tile(...) + with Reader(None, dataset=vrt_dst) as src: + src.tile(...) >>> with rasterio.open(src_path) as src_dst: - with COGReader(None, dataset=src_dst) as cog: - cog.tile(...) + with Reader(None, dataset=src_dst) as src: + src.tile(...) """ @@ -83,42 +80,23 @@ class COGReader(BaseReader): ) tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - minzoom: int = attr.ib(default=None) - maxzoom: int = attr.ib(default=None) - geographic_crs: CRS = attr.ib(default=WGS84_CRS) colormap: Dict = attr.ib(default=None) - # Define global options to be forwarded to functions reading the data (e.g `rio_tiler.reader.read`) - nodata: Optional[NoData] = attr.ib(default=None) - unscale: Optional[bool] = attr.ib(default=None) - resampling_method: Optional[Resampling] = attr.ib(default=None) - vrt_options: Optional[Dict] = attr.ib(default=None) - post_process: Optional[ - Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] - ] = attr.ib(default=None) - - # We use _kwargs to store values of nodata, unscale, vrt_options and resampling_method. - # _kwargs is used avoid having to set those values on each method call. - _kwargs: Dict[str, Any] = attr.ib(init=False, factory=dict) + options: reader.Options = attr.ib() # Context Manager to handle rasterio open/close _ctx_stack = attr.ib(init=False, factory=contextlib.ExitStack) + _minzoom: int = attr.ib(init=False, default=None) + _maxzoom: int = attr.ib(init=False, default=None) + + @options.default + def _options_default(self): + return {} def __attrs_post_init__(self): """Define _kwargs, open dataset and get info.""" - if self.nodata is not None: - self._kwargs["nodata"] = self.nodata - if self.unscale is not None: - self._kwargs["unscale"] = self.unscale - if self.resampling_method is not None: - self._kwargs["resampling_method"] = self.resampling_method - if self.vrt_options is not None: - self._kwargs["vrt_options"] = self.vrt_options - if self.post_process is not None: - self._kwargs["post_process"] = self.post_process - if not self.dataset: dataset = self._ctx_stack.enter_context(rasterio.open(self.input)) if dataset.gcps[0]: @@ -135,11 +113,6 @@ def __attrs_post_init__(self): self.bounds = tuple(self.dataset.bounds) self.crs = self.dataset.crs - self.nodata = self.nodata if self.nodata is not None else self.dataset.nodata - - if self.minzoom is None or self.maxzoom is None: - self._set_zooms() - if self.colormap is None: self._get_colormap() @@ -159,11 +132,11 @@ def __exit__(self, exc_type, exc_value, traceback): """Support using with Context Managers.""" self.close() - def get_zooms(self, tilesize: int = 256) -> Tuple[int, int]: - """Calculate raster min/max zoom level for input TMS.""" - if self.dataset.crs != self.tms.rasterio_crs: + def _dst_geom_in_tms_crs(self): + """Return dataset info in TMS projection.""" + if self.crs != self.tms.rasterio_crs: dst_affine, w, h = calculate_default_transform( - self.dataset.crs, + self.crs, self.tms.rasterio_crs, self.dataset.width, self.dataset.height, @@ -174,33 +147,69 @@ def get_zooms(self, tilesize: int = 256) -> Tuple[int, int]: w = self.dataset.width h = self.dataset.height - # The maxzoom is defined by finding the minimum difference between - # the raster resolution and the zoom level resolution - resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) - maxzoom = self.tms.zoom_for_res(resolution) + return dst_affine, w, h - # The minzoom is defined by the resolution of the maximum theoretical overview level - overview_level = get_maximum_overview_level(w, h, minsize=tilesize) - ovr_resolution = resolution * (2**overview_level) - minzoom = self.tms.zoom_for_res(ovr_resolution) + def get_minzoom(self) -> int: + """Define dataset minimum zoom level.""" + if self._minzoom is None: + # We assume the TMS tilesize to be constant over all matrices + # ref: https://github.com/OSGeo/gdal/blob/dc38aa64d779ecc45e3cd15b1817b83216cf96b8/gdal/frmts/gtiff/cogdriver.cpp#L274 + tilesize = self.tms.tileMatrix[0].tileWidth - return (minzoom, maxzoom) + try: + dst_affine, w, h = self._dst_geom_in_tms_crs() - def _set_zooms(self): - """Calculate raster min/max zoom level.""" - try: - minzoom, maxzoom = self.get_zooms() - except: # noqa - # if we can't get min/max zoom from the dataset we default to TMS min/max zoom - warnings.warn( - "Cannot dertermine min/max zoom based on dataset information, will default to TMS min/max zoom.", - UserWarning, - ) - minzoom, maxzoom = self.tms.minzoom, self.tms.maxzoom + # The minzoom is defined by the resolution of the maximum theoretical overview level + # We assume `tilesize`` is the smallest overview size + overview_level = get_maximum_overview_level(w, h, minsize=tilesize) + + # Get the resolution of the overview + resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) + ovr_resolution = resolution * (2**overview_level) - self.minzoom = self.minzoom if self.minzoom is not None else minzoom - self.maxzoom = self.maxzoom if self.maxzoom is not None else maxzoom - return + # Find what TMS matrix match the overview resolution + self._minzoom = self.tms.zoom_for_res(ovr_resolution) + + except: # noqa + # if we can't get max zoom from the dataset we default to TMS maxzoom + warnings.warn( + "Cannot determine minzoom based on dataset information, will default to TMS minzoom.", + UserWarning, + ) + self._minzoom = self.tms.minzoom + + return self._minzoom + + def get_maxzoom(self) -> int: + """Define dataset maximum zoom level.""" + if self._maxzoom is None: + try: + dst_affine, _, _ = self._dst_geom_in_tms_crs() + + # The maxzoom is defined by finding the minimum difference between + # the raster resolution and the zoom level resolution + resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) + self._maxzoom = self.tms.zoom_for_res(resolution) + + except: # noqa + # if we can't get min/max zoom from the dataset we default to TMS maxzoom + warnings.warn( + "Cannot determine maxzoom based on dataset information, will default to TMS maxzoom.", + UserWarning, + ) + self._maxzoom = self.tms.maxzoom + + return self._maxzoom + + @property + def minzoom(self): + """Return dataset minzoom.""" + return self.get_minzoom() + + @property + def maxzoom(self): + """Return dataset maxzoom.""" + return self.get_maxzoom() def _get_colormap(self): """Retrieve the internal colormap.""" @@ -217,12 +226,12 @@ def _get_descr(ix): """Return band description.""" return self.dataset.descriptions[ix - 1] or "" - if has_alpha_band(self.dataset): + if self.options.get("nodata", self.dataset.nodata) is not None: + nodata_type = "Nodata" + elif has_alpha_band(self.dataset): nodata_type = "Alpha" elif has_mask_band(self.dataset): nodata_type = "Mask" - elif self.nodata is not None: - nodata_type = "Nodata" else: nodata_type = "None" @@ -231,10 +240,10 @@ def _get_descr(ix): "minzoom": self.minzoom, "maxzoom": self.maxzoom, "band_metadata": [ - (f"{ix}", self.dataset.tags(ix)) for ix in self.dataset.indexes + (f"b{ix}", self.dataset.tags(ix)) for ix in self.dataset.indexes ], "band_descriptions": [ - (f"{ix}", _get_descr(ix)) for ix in self.dataset.indexes + (f"b{ix}", _get_descr(ix)) for ix in self.dataset.indexes ], "dtype": self.dataset.meta["dtype"], "colorinterp": [ @@ -257,7 +266,9 @@ def _get_descr(ix): meta.update({"colormap": self.colormap}) if nodata_type == "Nodata": - meta.update({"nodata_value": self.nodata}) + meta.update( + {"nodata_value": self.options.get("nodata", self.dataset.nodata)} + ) return Info(**meta) @@ -268,6 +279,8 @@ def statistics( percentiles: List[int] = [2, 98], hist_options: Optional[Dict] = None, max_size: int = 1024, + indexes: Optional[Indexes] = None, + expression: Optional[str] = None, **kwargs: Any, ) -> Dict[str, BandStatistics]: """Return bands statistics from a dataset. @@ -278,15 +291,17 @@ def statistics( percentiles (list of numbers, optional): list of percentile values to calculate. Defaults to `[2, 98]`. hist_options (dict, optional): Options to forward to numpy.histogram function. max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. Defaults to 1024. - kwargs (optional): Options to forward to `self.preview`. + kwargs (optional): Options to forward to `self.read`. Returns: Dict[str, rio_tiler.models.BandStatistics]: bands statistics. """ - kwargs = {**self._kwargs, **kwargs} + kwargs = {**self.options, **kwargs} - data = self.preview(max_size=max_size, **kwargs) + data = self.read( + max_size=max_size, indexes=indexes, expression=expression, **kwargs + ) hist_options = hist_options or {} @@ -312,6 +327,7 @@ def tile( indexes: Optional[Indexes] = None, expression: Optional[str] = None, tile_buffer: Optional[NumType] = None, + buffer: Optional[float] = None, **kwargs: Any, ) -> ImageData: """Read a Web Map tile from a COG. @@ -323,7 +339,8 @@ def tile( tilesize (int, optional): Output image size. Defaults to `256`. indexes (int or sequence of int, optional): Band indexes. expression (str, optional): rio-tiler expression (e.g. b1/b2+b3). - tile_buffer (int or float, optional): Buffer on each side of the given tile. It must be a multiple of `0.5`. Output **tilesize** will be expanded to `tilesize + 2 * tile_buffer` (e.g 0.5 = 257x257, 1.0 = 258x258). + tile_buffer (int or float, optional): Buffer on each side of the given tile. It must be a multiple of `0.5`. Output **tilesize** will be expanded to `tilesize + 2 * tile_buffer` (e.g 0.5 = 257x257, 1.0 = 258x258). DEPRECATED + buffer (float, optional): Buffer on each side of the given tile. It must be a multiple of `0.5`. Output **tilesize** will be expanded to `tilesize + 2 * tile_buffer` (e.g 0.5 = 257x257, 1.0 = 258x258). kwargs (optional): Options to forward to the `COGReader.part` method. Returns: @@ -336,25 +353,12 @@ def tile( ) tile_bounds = self.tms.xy_bounds(Tile(x=tile_x, y=tile_y, z=tile_z)) - if tile_buffer is not None: - if tile_buffer % 0.5: - raise IncorrectTileBuffer( - "`tile_buffer` must be a multiple of `0.5` (e.g: 0.5, 1, 1.5, ...)." - ) - x_res = (tile_bounds.right - tile_bounds.left) / tilesize - y_res = (tile_bounds.top - tile_bounds.bottom) / tilesize - - # Buffered Tile Bounds - tile_bounds = BoundingBox( - tile_bounds.left - x_res * tile_buffer, - tile_bounds.bottom - y_res * tile_buffer, - tile_bounds.right + x_res * tile_buffer, - tile_bounds.top + y_res * tile_buffer, + if tile_buffer: + warnings.warn( + "`tile_buffer` is deprecated, use `buffer`.", DeprecationWarning ) - - # Buffered Tile Size - tilesize += int(tile_buffer * 2) + buffer = tile_buffer return self.part( tile_bounds, @@ -365,6 +369,7 @@ def tile( max_size=None, indexes=indexes, expression=expression, + buffer=buffer, **kwargs, ) @@ -378,6 +383,7 @@ def part( max_size: Optional[int] = None, height: Optional[int] = None, width: Optional[int] = None, + buffer: Optional[float] = None, **kwargs: Any, ) -> ImageData: """Read part of a COG. @@ -391,16 +397,14 @@ def part( max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. height (int, optional): Output height of the array. width (int, optional): Output width of the array. + buffer (float, optional): Buffer on each side of the given aoi. It must be a multiple of `0.5`. Output **image size** will be expanded to `output imagesize + 2 * buffer` (e.g 0.5 = 257x257, 1.0 = 258x258). kwargs (optional): Options to forward to the `rio_tiler.reader.part` function. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. """ - kwargs = {**self._kwargs, **kwargs} - - if isinstance(indexes, int): - indexes = (indexes,) + kwargs = {**self.options, **kwargs} if indexes and expression: warnings.warn( @@ -414,7 +418,7 @@ def part( if not dst_crs: dst_crs = bounds_crs - data, mask = reader.part( + img = reader.part( self.dataset, bbox, max_size=max_size, @@ -423,27 +427,15 @@ def part( bounds_crs=bounds_crs, dst_crs=dst_crs, indexes=indexes, + buffer=buffer, **kwargs, ) + img.assets = [self.input] - if expression and indexes: - blocks = get_expression_blocks(expression) - bands = [f"b{bidx}" for bidx in indexes] - data = apply_expression(blocks, bands, data) - - if bounds_crs and bounds_crs != dst_crs: - bbox = transform_bounds(bounds_crs, dst_crs, *bbox, densify_pts=21) - - return ImageData( - data, - mask, - bounds=bbox, - crs=dst_crs, - assets=[self.input], - band_names=get_bands_names( - indexes=indexes, expression=expression, count=data.shape[0] - ), - ) + if expression: + return img.apply_expression(expression) + + return img def preview( self, @@ -462,51 +454,21 @@ def preview( max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. Defaults to 1024. height (int, optional): Output height of the array. width (int, optional): Output width of the array. - kwargs (optional): Options to forward to the `rio_tiler.reader.preview` function. + kwargs (optional): Options to forward to the `self.read` method. Returns: rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. """ - kwargs = {**self._kwargs, **kwargs} - - if isinstance(indexes, int): - indexes = (indexes,) - - if indexes and expression: - warnings.warn( - "Both expression and indexes passed; expression will overwrite indexes parameter.", - ExpressionMixingWarning, - ) - - if expression: - indexes = parse_expression(expression) - - data, mask = reader.preview( - self.dataset, + return self.read( indexes=indexes, + expression=expression, max_size=max_size, - width=width, height=height, + width=width, **kwargs, ) - if expression and indexes: - blocks = get_expression_blocks(expression) - bands = [f"b{bidx}" for bidx in indexes] - data = apply_expression(blocks, bands, data) - - return ImageData( - data, - mask, - bounds=self.bounds, - crs=self.crs, - assets=[self.input], - band_names=get_bands_names( - indexes=indexes, expression=expression, count=data.shape[0] - ), - ) - def point( self, lon: float, @@ -515,7 +477,7 @@ def point( indexes: Optional[Indexes] = None, expression: Optional[str] = None, **kwargs: Any, - ) -> List: + ) -> PointData: """Read a pixel value from a COG. Args: @@ -527,13 +489,10 @@ def point( kwargs (optional): Options to forward to the `rio_tiler.reader.point` function. Returns: - list: Pixel value per band indexes. + PointData """ - kwargs = {**self._kwargs, **kwargs} - - if isinstance(indexes, int): - indexes = (indexes,) + kwargs = {**self.options, **kwargs} if indexes and expression: warnings.warn( @@ -544,16 +503,15 @@ def point( if expression: indexes = parse_expression(expression) - point = reader.point( + pt = reader.point( self.dataset, (lon, lat), indexes=indexes, coord_crs=coord_crs, **kwargs ) + pt.assets = [self.input] - if expression and indexes: - blocks = get_expression_blocks(expression) - bands = [f"b{bidx}" for bidx in indexes] - point = apply_expression(blocks, bands, numpy.array(point)).tolist() + if expression: + return pt.apply_expression(expression) - return point + return pt def feature( self, @@ -565,6 +523,7 @@ def feature( max_size: Optional[int] = None, height: Optional[int] = None, width: Optional[int] = None, + buffer: Optional[NumType] = None, **kwargs: Any, ) -> ImageData: """Read part of a COG defined by a geojson feature. @@ -578,6 +537,7 @@ def feature( max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. height (int, optional): Output height of the array. width (int, optional): Output width of the array. + buffer (int or float, optional): Buffer on each side of the given aoi. It must be a multiple of `0.5`. Output **image size** will be expanded to `output imagesize + 2 * buffer` (e.g 0.5 = 257x257, 1.0 = 258x258). kwargs (optional): Options to forward to the `COGReader.part` method. Returns: @@ -604,6 +564,7 @@ def feature( width=width, height=height, vrt_options=vrt_options, + buffer=buffer, **kwargs, ) @@ -624,10 +585,7 @@ def read( rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. """ - kwargs = {**self._kwargs, **kwargs} - - if isinstance(indexes, int): - indexes = (indexes,) + kwargs = {**self.options, **kwargs} if indexes and expression: warnings.warn( @@ -638,27 +596,17 @@ def read( if expression: indexes = parse_expression(expression) - data, mask = reader.read(self.dataset, indexes=indexes, **kwargs) - - if expression and indexes: - blocks = get_expression_blocks(expression) - bands = [f"b{bidx}" for bidx in indexes] - data = apply_expression(blocks, bands, data) - - return ImageData( - data, - mask, - bounds=self.bounds, - crs=self.crs, - assets=[self.input], - band_names=get_bands_names( - indexes=indexes, expression=expression, count=data.shape[0] - ), - ) + img = reader.read(self.dataset, indexes=indexes, **kwargs) + img.assets = [self.input] + + if expression: + return img.apply_expression(expression) + + return img @attr.s -class GCPCOGReader(COGReader): +class GCPCOGReader(Reader): """Custom COG Reader with GCPS support. Attributes: @@ -668,21 +616,17 @@ class GCPCOGReader(COGReader): minzoom (int, optional): Overwrite Min Zoom level. maxzoom (int, optional): Overwrite Max Zoom level. colormap (dict, optional): Overwrite internal colormap. - nodata (int or float or str, optional): Global options, overwrite internal nodata value. - unscale (bool, optional): Global options, apply internal scale and offset on all read operations. - resampling_method (rasterio.enums.Resampling, optional): Global options, resampling method to use for read operations. - vrt_options (dict, optional): Global options, WarpedVRT options to use for read operations. - post_process (callable, optional): Global options, Function to apply after all read operations. + options (dict, optional): Options to forward to low-level reader methods. dataset (rasterio.vrtWarpedVRT): Warped VRT constructed with dataset GCPS info. **READ ONLY attribute**. Examples: - >>> with COGReader(src_path) as cog: + >>> with GCPCOGReader(src_path) as cog: cog.tile(...) assert cog.dataset assert cog.src_dataset >>> with rasterio.open(src_path) as src_dst: - with COGReader(None, src_dataset=src_dst) as cog: + with GCPCOGReader(None, src_dataset=src_dst) as cog: cog.tile(...) """ @@ -693,29 +637,23 @@ class GCPCOGReader(COGReader): ) tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - minzoom: int = attr.ib(default=None) - maxzoom: int = attr.ib(default=None) - geographic_crs: CRS = attr.ib(default=WGS84_CRS) colormap: Dict = attr.ib(default=None) - # Define global options to be forwarded to functions reading the data (e.g `rio_tiler.reader.read`) - nodata: Optional[NoData] = attr.ib(default=None) - unscale: Optional[bool] = attr.ib(default=None) - resampling_method: Optional[Resampling] = attr.ib(default=None) - vrt_options: Optional[Dict] = attr.ib(default=None) - post_process: Optional[ - Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] - ] = attr.ib(default=None) + options: reader.Options = attr.ib() # for GCPCOGReader, dataset is not a input option. dataset: WarpedVRT = attr.ib(init=False) + @options.default + def _options_default(self): + return {} + def __attrs_post_init__(self): """Define _kwargs, open dataset and get info.""" warnings.warn( - "GCPCOGReader is deprecated and will be removed in 4.0. Please use COGReader.", + "GCPCOGReader is deprecated and will be removed in 4.0. Please use Reader.", DeprecationWarning, ) @@ -730,3 +668,277 @@ def __attrs_post_init__(self): ) ) super().__attrs_post_init__() + + +@attr.s +class LocalTileMatrixSet: + """Fake TMS for non-geo image.""" + + width: int = attr.ib() + height: int = attr.ib() + tile_size: int = attr.ib(default=256) + + minzoom: int = attr.ib(init=False, default=0) + maxzoom: int = attr.ib(init=False) + + rasterio_crs: CRS = attr.ib(init=False, default=None) + + @maxzoom.default + def _maxzoom(self): + return get_maximum_overview_level( + self.width, + self.height, + minsize=self.tile_size, + ) + + def _ul(self, *tile: Tile) -> Coords: + """Return the upper left coordinate of the (x, y, z) tile.""" + t = _parse_tile_arg(*tile) + + res = 2.0 ** (self.maxzoom - t.z) + xcoord = self.tile_size * t.x * res + ycoord = self.tile_size * t.y * res + + return Coords(xcoord, ycoord) + + def xy_bounds(self, *tile: Tile) -> BoundingBox: + """Return the bounding box of the (x, y, z) tile""" + t = _parse_tile_arg(*tile) + left, top = self._ul(t) + right, bottom = self._ul(Tile(t.x + 1, t.y + 1, t.z)) + return BoundingBox(left, bottom, right, top) + + +@attr.s +class ImageReader(Reader): + """Non Geo Image Reader""" + + tms: TileMatrixSet = attr.ib(init=False) + + crs: CRS = attr.ib(init=False, default=None) + geographic_crs: CRS = attr.ib(init=False, default=None) + + transform: Affine = attr.ib(init=False) + + def __attrs_post_init__(self): + """Define _kwargs, open dataset and get info.""" + + if not self.dataset: + self.dataset = self._ctx_stack.enter_context(rasterio.open(self.input)) + + height, width = self.dataset.height, self.dataset.width + self.bounds = (0, height, width, 0) + self.transform = transform_from_bounds(*self.bounds, width=width, height=height) + + self.tms = LocalTileMatrixSet(width=width, height=height) + self._minzoom = self.tms.minzoom + self._maxzoom = self.tms.maxzoom + + if self.colormap is None: + self._get_colormap() + + if min( + self.dataset.width, self.dataset.height + ) > 512 and not self.dataset.overviews(1): + warnings.warn( + "The dataset has no Overviews. rio-tiler performances might be impacted.", + NoOverviewWarning, + ) + + def tile( # type: ignore + self, + tile_x: int, + tile_y: int, + tile_z: int, + tilesize: int = 256, + indexes: Optional[Indexes] = None, + expression: Optional[str] = None, + force_binary_mask: bool = True, + resampling_method: Resampling = "nearest", + unscale: bool = False, + post_process: Optional[ + Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] + ] = None, + ) -> ImageData: + """Read a Web Map tile from an Image. + + Args: + tile_x (int): Tile's horizontal index. + tile_y (int): Tile's vertical index. + tile_z (int): Tile's zoom level index. + tilesize (int, optional): Output image size. Defaults to `256`. + indexes (int or sequence of int, optional): Band indexes. + expression (str, optional): rio-tiler expression (e.g. b1/b2+b3). + force_binary_mask (bool, optional): Cast returned mask to binary values (0 or 255). Defaults to `True`. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. + post_process (callable, optional): Function to apply on output data and mask values. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. + + """ + if not self.tile_exists(tile_x, tile_y, tile_z): + raise TileOutsideBounds( + f"Tile {tile_z}/{tile_x}/{tile_y} is outside {self.input} bounds" + ) + + tile_bounds = self.tms.xy_bounds(Tile(x=tile_x, y=tile_y, z=tile_z)) + + return self.part( + tile_bounds, + height=tilesize, + width=tilesize, + max_size=None, + indexes=indexes, + expression=expression, + force_binary_mask=force_binary_mask, + resampling_method=resampling_method, + unscale=unscale, + post_process=post_process, + ) + + def part( # type: ignore + self, + bbox: BBox, + indexes: Optional[Union[int, Sequence]] = None, + expression: Optional[str] = None, + max_size: Optional[int] = None, + height: Optional[int] = None, + width: Optional[int] = None, + force_binary_mask: bool = True, + resampling_method: Resampling = "nearest", + unscale: bool = False, + post_process: Optional[ + Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] + ] = None, + ) -> ImageData: + """Read part of an Image. + + Args: + bbox (tuple): Output bounds (left, bottom, right, top). + indexes (sequence of int or int, optional): Band indexes. + expression (str, optional): rio-tiler expression (e.g. b1/b2+b3). + max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. + height (int, optional): Output height of the array. + width (int, optional): Output width of the array. + force_binary_mask (bool, optional): Cast returned mask to binary values (0 or 255). Defaults to `True`. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. + post_process (callable, optional): Function to apply on output data and mask values. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. + + """ + if indexes and expression: + warnings.warn( + "Both expression and indexes passed; expression will overwrite indexes parameter.", + ExpressionMixingWarning, + ) + + if expression: + indexes = parse_expression(expression) + + window = window_from_bounds(*bbox, transform=self.transform) + img = reader.read( + self.dataset, + window=window, + max_size=max_size, + width=width, + height=height, + indexes=indexes, + force_binary_mask=force_binary_mask, + resampling_method=resampling_method, + unscale=unscale, + post_process=post_process, + ) + img.assets = [self.input] + + if expression: + return img.apply_expression(expression) + + return img + + def point( # type: ignore + self, + x: float, + y: float, + indexes: Optional[Indexes] = None, + expression: Optional[str] = None, + unscale: bool = False, + post_process: Optional[ + Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] + ] = None, + ) -> PointData: + """Read a pixel value from an Image. + + Args: + lon (float): X coordinate. + lat (float): Y coordinate. + indexes (sequence of int or int, optional): Band indexes. + expression (str, optional): rio-tiler expression (e.g. b1/b2+b3). + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. + post_process (callable, optional): Function to apply on output data and mask values. + + Returns: + PointData + + """ + if not ((0 <= x < self.dataset.width) and (0 <= y < self.dataset.height)): + raise PointOutsideBounds("Point is outside dataset bounds") + + img = self.read( + indexes=indexes, + expression=expression, + unscale=unscale, + post_process=post_process, + window=Window(x, y, 1, 1), + ) + + return PointData( + img.data[:, 0, 0], + numpy.array([img.mask[0, 0]]), + assets=img.assets, + coordinates=self.dataset.xy(x, y), + crs=self.dataset.crs, + band_names=img.band_names, + ) + + def feature( # type: ignore + self, + shape: Dict, + indexes: Optional[Indexes] = None, + expression: Optional[str] = None, + max_size: Optional[int] = None, + height: Optional[int] = None, + width: Optional[int] = None, + force_binary_mask: bool = True, + resampling_method: Resampling = "nearest", + unscale: bool = False, + post_process: Optional[ + Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] + ] = None, + ) -> ImageData: + """Read part of an Image defined by a geojson feature.""" + bbox = featureBounds(shape) + + # If Image Origin is top Left (non-geo) we need to invert the bbox + bbox = [bbox[0], bbox[3], bbox[2], bbox[1]] + + img = self.part( + bbox, + indexes=indexes, + max_size=max_size, + height=height, + width=width, + force_binary_mask=force_binary_mask, + resampling_method=resampling_method, + unscale=unscale, + post_process=post_process, + ) + + shape = shape.get("geometry", shape) + mask = geometry_mask([shape], (img.height, img.width), self.transform) + img.mask = mask * 255 + return img diff --git a/rio_tiler/io/stac.py b/rio_tiler/io/stac.py index d7d32913..058dd611 100644 --- a/rio_tiler/io/stac.py +++ b/rio_tiler/io/stac.py @@ -12,11 +12,11 @@ from morecantile import TileMatrixSet from rasterio.crs import CRS -from ..constants import WEB_MERCATOR_TMS, WGS84_CRS -from ..errors import InvalidAssetName, MissingAssets -from ..utils import aws_get_object -from .base import BaseReader, MultiBaseReader -from .cogeo import COGReader +from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS +from rio_tiler.errors import InvalidAssetName, MissingAssets +from rio_tiler.io.base import BaseReader, MultiBaseReader +from rio_tiler.io.rasterio import Reader +from rio_tiler.utils import aws_get_object DEFAULT_VALID_TYPE = { "image/tiff; application=geotiff", @@ -130,14 +130,15 @@ class STACReader(MultiBaseReader): Attributes: input (str): STAC Item path, URL or S3 URL. item (dict or pystac.Item, STAC): Stac Item. + tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. minzoom (int, optional): Set minzoom for the tiles. maxzoom (int, optional): Set maxzoom for the tiles. geographic_crs (rasterio.crs.CRS, optional): CRS to use as geographic coordinate system. Defaults to WGS84. - include (set of string, optional): Only Include specific assets. - exclude (set of string, optional): Exclude specific assets. + include_assets (set of string, optional): Only Include specific assets. + exclude_assets (set of string, optional): Exclude specific assets. include_asset_types (set of string, optional): Only include some assets base on their type. exclude_asset_types (set of string, optional): Exclude some assets base on their type. - reader (rio_tiler.io.BaseReader, optional): rio-tiler Reader. Defaults to `rio_tiler.io.COGReader`. + reader (rio_tiler.io.BaseReader, optional): rio-tiler Reader. Defaults to `rio_tiler.io.Reader`. reader_options (dict, optional): Additional option to forward to the Reader. Defaults to `{}`. fetch_options (dict, optional): Options to pass to `rio_tiler.io.stac.fetch` function fetching the STAC Items. Defaults to `{}`. @@ -164,8 +165,8 @@ class STACReader(MultiBaseReader): item: pystac.Item = attr.ib(default=None, converter=_to_pystac_item) tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - minzoom: int = attr.ib(default=None) - maxzoom: int = attr.ib(default=None) + minzoom: int = attr.ib() + maxzoom: int = attr.ib() geographic_crs: CRS = attr.ib(default=WGS84_CRS) @@ -175,7 +176,7 @@ class STACReader(MultiBaseReader): include_asset_types: Set[str] = attr.ib(default=DEFAULT_VALID_TYPE) exclude_asset_types: Optional[Set[str]] = attr.ib(default=None) - reader: Type[BaseReader] = attr.ib(default=COGReader) + reader: Type[BaseReader] = attr.ib(default=Reader) reader_options: Dict = attr.ib(factory=dict) fetch_options: Dict = attr.ib(factory=dict) @@ -202,13 +203,13 @@ def __attrs_post_init__(self): if not self.assets: raise MissingAssets("No valid asset found") - if self.minzoom is None: - # TODO get minzoom from PROJ extension - self.minzoom = self.tms.minzoom + @minzoom.default + def _minzoom(self): + return self.tms.minzoom - if self.maxzoom is None: - # TODO get maxzoom from PROJ extension - self.maxzoom = self.tms.maxzoom + @maxzoom.default + def _maxzoom(self): + return self.tms.maxzoom def _get_asset_url(self, asset: str) -> str: """Validate asset names and return asset's url. diff --git a/rio_tiler/io/xarray.py b/rio_tiler/io/xarray.py new file mode 100644 index 00000000..4842b098 --- /dev/null +++ b/rio_tiler/io/xarray.py @@ -0,0 +1,412 @@ +"""rio_tiler.io.xarray: Xarray Reader.""" +from __future__ import annotations + +import warnings +from typing import Any, Dict, List, Optional + +import attr +from morecantile import Tile, TileMatrixSet +from rasterio.crs import CRS +from rasterio.enums import Resampling +from rasterio.features import is_valid_geom +from rasterio.rio.overview import get_maximum_overview_level +from rasterio.transform import from_bounds, rowcol +from rasterio.warp import calculate_default_transform +from rasterio.warp import transform as transform_coords + +from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS +from rio_tiler.errors import PointOutsideBounds, RioTilerError, TileOutsideBounds +from rio_tiler.io.base import BaseReader +from rio_tiler.models import BandStatistics, ImageData, Info, PointData +from rio_tiler.types import BBox + +try: + import xarray +except ImportError: # pragma: nocover + xarray = None # type: ignore + +try: + import rioxarray +except ImportError: # pragma: nocover + rioxarray = None # type: ignore + + +@attr.s +class XarrayReader(BaseReader): + """Xarray Reader. + + Attributes: + dataset (xarray.DataArray): Xarray DataArray dataset. + tms (morecantile.TileMatrixSet, optional): TileMatrixSet grid definition. Defaults to `WebMercatorQuad`. + geographic_crs (rasterio.crs.CRS, optional): CRS to use as geographic coordinate system. Defaults to WGS84. + + Examples: + >>> ds = xarray.open_dataset( + "https://pangeo.blob.core.windows.net/pangeo-public/daymet-rio-tiler/na-wgs84.zarr", + engine="zarr", + decode_coords="all", + consolidated=True, + ) + da = ds["tmax"] + + with XarrayReader(da) as dst: + img = dst.tile(...) + + """ + + input: xarray.DataArray = attr.ib() + + tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) + geographic_crs: CRS = attr.ib(default=WGS84_CRS) + + _minzoom: int = attr.ib(init=False, default=None) + _maxzoom: int = attr.ib(init=False, default=None) + + _dims: List = attr.ib(init=False, factory=list) + + def __attrs_post_init__(self): + """Set bounds and CRS.""" + assert xarray is not None, "xarray must be installed to use XarrayReader" + assert rioxarray is not None, "rioxarray must be installed to use XarrayReader" + + self.bounds = tuple(self.input.rio.bounds()) + self.crs = self.input.rio.crs + + self._dims = [ + d + for d in self.input.dims + if d not in [self.input.rio.x_dim, self.input.rio.y_dim] + ] + + def _dst_geom_in_tms_crs(self): + """Return dataset info in TMS projection.""" + if self.crs != self.tms.rasterio_crs: + dst_affine, w, h = calculate_default_transform( + self.crs, + self.tms.rasterio_crs, + self.input.rio.width, + self.input.rio.height, + *self.bounds, + ) + else: + dst_affine = list(self.input.rio.transform()) + w = self.input.rio.width + h = self.input.rio.height + + return dst_affine, w, h + + def get_minzoom(self) -> int: + """Define dataset minimum zoom level.""" + if self._minzoom is None: + # We assume the TMS tilesize to be constant over all matrices + # ref: https://github.com/OSGeo/gdal/blob/dc38aa64d779ecc45e3cd15b1817b83216cf96b8/gdal/frmts/gtiff/cogdriver.cpp#L274 + tilesize = self.tms.tileMatrix[0].tileWidth + + try: + dst_affine, w, h = self._dst_geom_in_tms_crs() + + # The minzoom is defined by the resolution of the maximum theoretical overview level + # We assume `tilesize`` is the smallest overview size + overview_level = get_maximum_overview_level(w, h, minsize=tilesize) + + # Get the resolution of the overview + resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) + ovr_resolution = resolution * (2**overview_level) + + # Find what TMS matrix match the overview resolution + self._minzoom = self.tms.zoom_for_res(ovr_resolution) + + except: # noqa + # if we can't get min/max zoom from the dataset we default to TMS maxzoom + warnings.warn( + "Cannot determine maxzoom based on dataset information, will default to TMS maxzoom.", + UserWarning, + ) + self._minzoom = self.tms.maxzoom + + return self._minzoom + + def get_maxzoom(self) -> int: + """Define dataset maximum zoom level.""" + if self._maxzoom is None: + try: + dst_affine, _, _ = self._dst_geom_in_tms_crs() + + # The maxzoom is defined by finding the minimum difference between + # the raster resolution and the zoom level resolution + resolution = max(abs(dst_affine[0]), abs(dst_affine[4])) + self._maxzoom = self.tms.zoom_for_res(resolution) + + except: # noqa + # if we can't get min/max zoom from the dataset we default to TMS maxzoom + warnings.warn( + "Cannot determine maxzoom based on dataset information, will default to TMS maxzoom.", + UserWarning, + ) + self._maxzoom = self.tms.maxzoom + + return self._maxzoom + + @property + def minzoom(self): + """Return dataset minzoom.""" + return self.get_minzoom() + + @property + def maxzoom(self): + """Return dataset maxzoom.""" + return self.get_maxzoom() + + def info(self) -> Info: + """Return xarray.DataArray info.""" + bands = [str(band) for d in self._dims for band in self.input[d].values] + metadata = [band.attrs for d in self._dims for band in self.input[d]] + + meta = { + "bounds": self.geographic_bounds, + "minzoom": self.minzoom, + "maxzoom": self.maxzoom, + "band_metadata": [(f"b{ix}", v) for ix, v in enumerate(metadata, 1)], + "band_descriptions": [(f"b{ix}", v) for ix, v in enumerate(bands, 1)], + "dtype": str(self.input.dtype), + "nodata_type": "Nodata" if self.input.rio.nodata is not None else "None", + "name": self.input.name, + "count": self.input.rio.count, + "width": self.input.rio.width, + "height": self.input.rio.height, + "attrs": self.input.attrs, + } + return Info(**meta) + + def statistics( + self, + categorical: bool = False, + categories: Optional[List[float]] = None, + percentiles: List[int] = [2, 98], + hist_options: Optional[Dict] = None, + max_size: int = 1024, + **kwargs: Any, + ) -> Dict[str, BandStatistics]: + """Return bands statistics from a dataset.""" + raise NotImplementedError + + def tile( + self, + tile_x: int, + tile_y: int, + tile_z: int, + tilesize: int = 256, + resampling_method: Resampling = "nearest", + ) -> ImageData: + """Read a Web Map tile from a dataset. + + Args: + tile_x (int): Tile's horizontal index. + tile_y (int): Tile's vertical index. + tile_z (int): Tile's zoom level index. + tilesize (int, optional): Output image size. Defaults to `256`. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and tile spatial info. + + """ + if not self.tile_exists(tile_x, tile_y, tile_z): + raise TileOutsideBounds( + f"Tile {tile_z}/{tile_x}/{tile_y} is outside bounds" + ) + + tile_bounds = self.tms.xy_bounds(Tile(x=tile_x, y=tile_y, z=tile_z)) + + # Create source array by clipping the xarray dataset to extent of the tile. + ds = self.input.rio.clip_box(*tile_bounds, crs=self.tms.rasterio_crs) + ds = ds.rio.reproject( + self.tms.rasterio_crs, + shape=(tilesize, tilesize), + transform=from_bounds(*tile_bounds, height=tilesize, width=tilesize), + resampling=Resampling[resampling_method], + ) + + # Forward valid_min/valid_max to the ImageData object + minv, maxv = ds.attrs.get("valid_min"), ds.attrs.get("valid_max") + stats = None + if minv is not None and maxv is not None: + stats = ((minv, maxv),) * ds.rio.count + + band_names = [str(band) for d in self._dims for band in self.input[d].values] + + return ImageData( + ds.data, + bounds=tile_bounds, + crs=self.tms.rasterio_crs, + dataset_statistics=stats, + band_names=band_names, + ) + + def part( + self, + bbox: BBox, + dst_crs: Optional[CRS] = None, + bounds_crs: CRS = WGS84_CRS, + resampling_method: Resampling = "nearest", + ) -> ImageData: + """Read part of a dataset. + + Args: + bbox (tuple): Output bounds (left, bottom, right, top) in target crs ("dst_crs"). + dst_crs (rasterio.crs.CRS, optional): Overwrite target coordinate reference system. + bounds_crs (rasterio.crs.CRS, optional): Bounds Coordinate Reference System. Defaults to `epsg:4326`. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. + + """ + dst_crs = dst_crs or bounds_crs + ds = self.input.rio.clip_box(*bbox, crs=bounds_crs) + + if dst_crs != self.crs: + dst_transform, w, h = calculate_default_transform( + self.crs, + dst_crs, + ds.rio.width, + ds.rio.height, + *ds.rio.bounds(), + ) + ds = ds.rio.reproject( + dst_crs, + shape=(h, w), + transform=dst_transform, + resampling=Resampling[resampling_method], + ) + + # Forward valid_min/valid_max to the ImageData object + minv, maxv = ds.attrs.get("valid_min"), ds.attrs.get("valid_max") + stats = None + if minv is not None and maxv is not None: + stats = ((minv, maxv),) * ds.rio.count + + band_names = [str(band) for d in self._dims for band in self.input[d].values] + + return ImageData( + ds.data, + bounds=ds.rio.bounds(), + crs=ds.rio.crs, + dataset_statistics=stats, + band_names=band_names, + ) + + def preview( + self, + max_size: int = 1024, + height: Optional[int] = None, + width: Optional[int] = None, + ) -> ImageData: + """Return a preview of a dataset. + + Args: + max_size (int, optional): Limit the size of the longest dimension of the dataset read, respecting bounds X/Y aspect ratio. Defaults to 1024. + height (int, optional): Output height of the array. + width (int, optional): Output width of the array. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. + + """ + raise NotImplementedError + + def point( + self, + lon: float, + lat: float, + coord_crs: CRS = WGS84_CRS, + ) -> PointData: + """Read a pixel value from a dataset. + + Args: + lon (float): Longitude. + lat (float): Latitude. + coord_crs (rasterio.crs.CRS, optional): Coordinate Reference System of the input coords. Defaults to `epsg:4326`. + + Returns: + PointData + + """ + ds_lon, ds_lat = transform_coords(coord_crs, self.crs, [lon], [lat]) + + if not ( + (self.bounds[0] < ds_lon[0] < self.bounds[2]) + and (self.bounds[1] < ds_lat[0] < self.bounds[3]) + ): + raise PointOutsideBounds("Point is outside dataset bounds") + + x, y = rowcol(self.input.rio.transform(), ds_lon, ds_lat) + + band_names = [str(band) for d in self._dims for band in self.input[d].values] + + return PointData( + self.input.data[:, y[0], y[0]], + coordinates=(lon, lat), + crs=coord_crs, + band_names=band_names, + ) + + def feature( + self, + shape: Dict, + dst_crs: Optional[CRS] = None, + shape_crs: CRS = WGS84_CRS, + resampling_method: Resampling = "nearest", + ) -> ImageData: + """Read part of a dataset defined by a geojson feature. + + Args: + shape (dict): Valid GeoJSON feature. + dst_crs (rasterio.crs.CRS, optional): Overwrite target coordinate reference system. + shape_crs (rasterio.crs.CRS, optional): Input geojson coordinate reference system. Defaults to `epsg:4326`. + + Returns: + rio_tiler.models.ImageData: ImageData instance with data, mask and input spatial info. + + """ + if not dst_crs: + dst_crs = shape_crs + + if "geometry" in shape: + shape = shape["geometry"] + + if not is_valid_geom(shape): + raise RioTilerError("Invalid geometry") + + ds = self.input.rio.clip([shape], crs=shape_crs) + + if dst_crs != self.crs: + dst_transform, w, h = calculate_default_transform( + self.crs, + dst_crs, + ds.rio.width, + ds.rio.height, + *ds.rio.bounds(), + ) + ds = ds.rio.reproject( + dst_crs, + shape=(h, w), + transform=dst_transform, + resampling=Resampling[resampling_method], + ) + + # Forward valid_min/valid_max to the ImageData object + minv, maxv = ds.attrs.get("valid_min"), ds.attrs.get("valid_max") + stats = None + if minv is not None and maxv is not None: + stats = ((minv, maxv),) * ds.rio.count + + band_names = [str(band) for d in self._dims for band in self.input[d].values] + + return ImageData( + ds.data, + bounds=ds.rio.bounds(), + crs=ds.rio.crs, + dataset_statistics=stats, + band_names=band_names, + ) diff --git a/rio_tiler/models.py b/rio_tiler/models.py index e67dee30..3e20b236 100644 --- a/rio_tiler/models.py +++ b/rio_tiler/models.py @@ -17,9 +17,10 @@ from rio_color.operations import parse_operations from rio_color.utils import scale_dtype, to_math_type -from .errors import InvalidDatatypeWarning -from .types import ColorMapType, GDALColorMapType, IntervalTuple, NumType -from .utils import linear_rescale, render, resize_array +from rio_tiler.errors import InvalidDatatypeWarning +from rio_tiler.expression import apply_expression, get_expression_blocks +from rio_tiler.types import ColorMapType, GDALColorMapType, IntervalTuple, NumType +from rio_tiler.utils import linear_rescale, render, resize_array class NodataTypes(str, Enum): @@ -130,6 +131,119 @@ def rescale_image( return data.astype(out_dtype) +@attr.s +class PointData: + """Point Data class. + + Attributes: + data (numpy.ndarray): pixel values. + mask (numpy.ndarray): rasterio mask values. + band_names (list): name of each band. Defaults to `["1", "2", "3"]` for 3 bands image. + coordinates (tuple): Point's coordinates. + crs (rasterio.crs.CRS, optional): Coordinates Reference System of the bounds. + assets (list, optional): list of assets used to construct the data values. + metadata (dict, optional): Additional metadata. Defaults to `{}`. + + """ + + data: numpy.ndarray = attr.ib() + mask: numpy.ndarray = attr.ib() + band_names: List[str] = attr.ib() + coordinates: Optional[Tuple[float, float]] = attr.ib(default=None) + crs: Optional[CRS] = attr.ib(default=None) + assets: Optional[List] = attr.ib(default=None) + metadata: Optional[Dict] = attr.ib(factory=dict) + + @data.validator + def _validate_data(self, attribute, value): + """PointsData data has to be a 1d array.""" + if not len(value.shape) == 1: + raise ValueError("PointsData data has to be a 1D array") + + @coordinates.validator + def _validate_coordinates(self, attribute, value): + """coordinates has to be a 2d list.""" + if value and not len(value) == 2: + raise ValueError("Coordinates data has to be a 2d list") + + @band_names.default + def _default_names(self): + return [f"b{ix + 1}" for ix in range(self.count)] + + @mask.default + def _default_mask(self): + return numpy.zeros(self.data.shape[0], dtype="uint8") + 255 + + def __iter__(self): + """Allow for variable expansion.""" + for i in self.data: + yield i + + @property + def count(self) -> int: + """Number of band.""" + return self.data.shape[0] + + @classmethod + def create_from_list(cls, data: Sequence["PointData"]): + """Create PointData from a sequence of PointsData objects. + + Args: + data (sequence): sequence of PointData. + + """ + # validate coordinates + if all([pt.coordinates or pt.crs or None for pt in data]): + lon, lat, crs = zip(*[(*(pt.coordinates or []), pt.crs) for pt in data]) + if len(set(lon)) > 1 or len(set(lat)) > 1 or len(set(crs)) > 1: + raise Exception( + "Cannot concatenate points with different coordinates/CRS." + ) + + arr = numpy.concatenate([pt.data for pt in data]) + mask = numpy.concatenate([pt.mask for pt in data]) + + assets = list( + dict.fromkeys( + itertools.chain.from_iterable([pt.assets for pt in data if pt.assets]) + ) + ) + + band_names = list( + itertools.chain.from_iterable( + [pt.band_names for pt in data if pt.band_names] + ) + ) + + return cls( + arr, + mask, + assets=assets, + crs=data[0].crs, + coordinates=data[0].coordinates, + band_names=band_names, + ) + + def as_masked(self) -> numpy.ma.MaskedArray: + """return a numpy masked array.""" + data = numpy.ma.array(self.data) + data.mask = self.mask == 0 + return data + + def apply_expression(self, expression: str) -> "PointData": + """Apply expression to the image data.""" + blocks = get_expression_blocks(expression) + return PointData( + apply_expression(blocks, self.band_names, self.data), + self.mask, + assets=self.assets, + crs=self.crs, + coordinates=self.coordinates, + band_names=blocks, + metadata=self.metadata, + ) + + @attr.s class ImageData: """Image Data class. @@ -142,6 +256,7 @@ class ImageData: crs (rasterio.crs.CRS, optional): Coordinates Reference System of the bounds. metadata (dict, optional): Additional metadata. Defaults to `{}`. band_names (list, optional): name of each band. Defaults to `["1", "2", "3"]` for 3 bands image. + dataset_statistics (list, optional): dataset statistics `[(min, max), (min, max)]` """ @@ -152,6 +267,7 @@ class ImageData: crs: Optional[CRS] = attr.ib(default=None) metadata: Optional[Dict] = attr.ib(factory=dict) band_names: List[str] = attr.ib() + dataset_statistics: Optional[Sequence[Tuple[float, float]]] = attr.ib(default=None) @data.validator def _validate_data(self, attribute, value): @@ -163,7 +279,7 @@ def _validate_data(self, attribute, value): @band_names.default def _default_names(self): - return [f"{ix + 1}" for ix in range(self.count)] + return [f"b{ix + 1}" for ix in range(self.count)] @mask.default def _default_mask(self): @@ -217,8 +333,21 @@ def create_from_list(cls, data: Sequence["ImageData"]): ) ) + stats = list( + itertools.chain.from_iterable( + [img.dataset_statistics for img in data if img.dataset_statistics] + ) + ) + dataset_statistics = stats if len(stats) == len(band_names) else None + return cls( - arr, mask, assets=assets, crs=crs, bounds=bounds, band_names=band_names + arr, + mask, + assets=assets, + crs=crs, + bounds=bounds, + band_names=band_names, + dataset_statistics=dataset_statistics, ) def as_masked(self) -> numpy.ma.MaskedArray: @@ -266,13 +395,46 @@ def rescale( out_dtype: Union[str, numpy.number] = "uint8", ): """Rescale data in place.""" - self.data = rescale_image(self.data, self.mask, in_range, out_range, out_dtype) + self.data = rescale_image( + self.data.copy(), + self.mask, + in_range=in_range, + out_range=out_range, + out_dtype=out_dtype, + ) def apply_color_formula(self, color_formula: Optional[str]): """Apply rio-color formula in place.""" - self.data[self.data < 0] = 0 + out = self.data.copy() + out[out < 0] = 0 + for ops in parse_operations(color_formula): - self.data = scale_dtype(ops(to_math_type(self.data)), numpy.uint8) + out = scale_dtype(ops(to_math_type(out)), numpy.uint8) + + self.data = out + + def apply_expression(self, expression: str) -> "ImageData": + """Apply expression to the image data.""" + blocks = get_expression_blocks(expression) + + stats = self.dataset_statistics + if stats: + res = [] + for prod in itertools.product(*stats): # type: ignore + res.append(apply_expression(blocks, self.band_names, numpy.array(prod))) + + stats = list(zip([min(r) for r in zip(*res)], [max(r) for r in zip(*res)])) + + return ImageData( + apply_expression(blocks, self.band_names, self.data), + self.mask.copy(), + assets=self.assets, + crs=self.crs, + bounds=self.bounds, + band_names=blocks, + metadata=self.metadata, + dataset_statistics=stats, + ) def post_process( self, @@ -346,22 +508,23 @@ def render( kwargs.update({"crs": self.crs}) data = self.data.copy() - datatype_range = (dtype_ranges[str(data.dtype)],) + mask = self.mask.copy() + datatype_range = self.dataset_statistics or (dtype_ranges[str(data.dtype)],) if not colormap: if img_format in ["PNG"] and data.dtype not in ["uint8", "uint16"]: warnings.warn( - f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds.", + f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.", InvalidDatatypeWarning, ) - data = rescale_image(data, self.mask, in_range=datatype_range) + data = rescale_image(data, mask, in_range=datatype_range) elif img_format in ["JPEG", "WEBP"] and data.dtype not in ["uint8"]: warnings.warn( - f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds.", + f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.", InvalidDatatypeWarning, ) - data = rescale_image(data, self.mask, in_range=datatype_range) + data = rescale_image(data, mask, in_range=datatype_range) elif img_format in ["JP2OPENJPEG"] and data.dtype not in [ "uint8", @@ -369,14 +532,14 @@ def render( "uint16", ]: warnings.warn( - f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds.", + f"Invalid type: `{data.dtype}` for the `{img_format}` driver. Data will be rescaled using min/max type bounds or dataset_statistics.", InvalidDatatypeWarning, ) - data = rescale_image(data, self.mask, in_range=datatype_range) + data = rescale_image(data, mask, in_range=datatype_range) if add_mask: return render( - data, self.mask, img_format=img_format, colormap=colormap, **kwargs + data, mask, img_format=img_format, colormap=colormap, **kwargs ) return render(data, img_format=img_format, colormap=colormap, **kwargs) diff --git a/rio_tiler/mosaic/methods/defaults.py b/rio_tiler/mosaic/methods/defaults.py index ac460ebb..a1728cb1 100644 --- a/rio_tiler/mosaic/methods/defaults.py +++ b/rio_tiler/mosaic/methods/defaults.py @@ -2,7 +2,7 @@ import numpy -from .base import MosaicMethodBase +from rio_tiler.mosaic.methods.base import MosaicMethodBase class FirstMethod(MosaicMethodBase): diff --git a/rio_tiler/mosaic/reader.py b/rio_tiler/mosaic/reader.py index 3b94f2ae..a8e2abbf 100644 --- a/rio_tiler/mosaic/reader.py +++ b/rio_tiler/mosaic/reader.py @@ -5,14 +5,14 @@ from rasterio.crs import CRS -from ..constants import MAX_THREADS -from ..errors import EmptyMosaicError, InvalidMosaicMethod, TileOutsideBounds -from ..models import ImageData -from ..tasks import create_tasks, filter_tasks -from ..types import BBox -from ..utils import _chunks -from .methods.base import MosaicMethodBase -from .methods.defaults import FirstMethod +from rio_tiler.constants import MAX_THREADS +from rio_tiler.errors import EmptyMosaicError, InvalidMosaicMethod, TileOutsideBounds +from rio_tiler.models import ImageData +from rio_tiler.mosaic.methods.base import MosaicMethodBase +from rio_tiler.mosaic.methods.defaults import FirstMethod +from rio_tiler.tasks import create_tasks, filter_tasks +from rio_tiler.types import BBox +from rio_tiler.utils import _chunks def mosaic_reader( diff --git a/rio_tiler/reader.py b/rio_tiler/reader.py index b75fc8bc..301fe310 100644 --- a/rio_tiler/reader.py +++ b/rio_tiler/reader.py @@ -1,8 +1,9 @@ """rio-tiler.reader: low level reader.""" +import contextlib import math import warnings -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, Optional, Tuple, TypedDict, Union import numpy from affine import Affine @@ -14,147 +15,291 @@ from rasterio.warp import transform as transform_coords from rasterio.warp import transform_bounds -from .constants import WGS84_CRS -from .errors import AlphaBandWarning, PointOutsideBounds, TileOutsideBounds -from .types import BBox, DataMaskType, Indexes, NoData -from .utils import _requested_tile_aligned_with_internal_tile as is_aligned -from .utils import get_vrt_transform, has_alpha_band, non_alpha_indexes +from rio_tiler.constants import WGS84_CRS +from rio_tiler.errors import InvalidBufferSize, PointOutsideBounds, TileOutsideBounds +from rio_tiler.models import ImageData, PointData +from rio_tiler.types import BBox, DataMaskType, Indexes, NoData +from rio_tiler.utils import _requested_tile_aligned_with_internal_tile as is_aligned +from rio_tiler.utils import get_vrt_transform, has_alpha_band, non_alpha_indexes + + +class Options(TypedDict, total=False): + """Reader Options.""" + + nodata: Optional[NoData] + vrt_options: Optional[Dict] + resampling_method: Optional[Resampling] + unscale: Optional[bool] + post_process: Optional[Callable[[numpy.ndarray, numpy.ndarray], DataMaskType]] + + +def _get_width_height(max_size, dataset_height, dataset_width) -> Tuple[int, int]: + """Get Output Width/Height based on a max_size and dataset shape.""" + if max(dataset_height, dataset_width) < max_size: + return dataset_height, dataset_width + + ratio = dataset_height / dataset_width + if ratio > 1: + height = max_size + width = math.ceil(height / ratio) + else: + width = max_size + height = math.ceil(width * ratio) + + return height, width + + +def _apply_buffer( + buffer: float, + bounds: BBox, + height: int, + width: int, +) -> Tuple[BBox, int, int]: + """Apply buffer on bounds.""" + x_res = (bounds[2] - bounds[0]) / width + y_res = (bounds[3] - bounds[1]) / height + + # apply buffer to bounds + bounds = ( + bounds[0] - x_res * buffer, + bounds[1] - y_res * buffer, + bounds[2] + x_res * buffer, + bounds[3] + y_res * buffer, + ) + + # new output size + height += int(buffer * 2) + width += int(buffer * 2) + + return bounds, height, width def read( src_dst: Union[DatasetReader, DatasetWriter, WarpedVRT], + dst_crs: Optional[CRS] = None, height: Optional[int] = None, width: Optional[int] = None, + max_size: Optional[int] = None, indexes: Optional[Indexes] = None, window: Optional[windows.Window] = None, force_binary_mask: bool = True, nodata: Optional[NoData] = None, - unscale: bool = False, - resampling_method: Resampling = "nearest", vrt_options: Optional[Dict] = None, + resampling_method: Resampling = "nearest", + unscale: bool = False, post_process: Optional[ Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] ] = None, -) -> DataMaskType: +) -> ImageData: """Low level read function. Args: src_dst (rasterio.io.DatasetReader or rasterio.io.DatasetWriter or rasterio.vrt.WarpedVRT): Rasterio dataset. - height (int, optional): Output height of the array. - width (int, optional): Output width of the array. + dst_crs (rasterio.crs.CRS, optional): Target coordinate reference system. + height (int, optional): Output height of the image. + width (int, optional): Output width of the image. + max_size (int, optional): Limit output size image if not width and height. indexes (sequence of int or int, optional): Band indexes. window (rasterio.windows.Window, optional): Window to read. - force_binary_mask (bool, optional): Cast returned mask to binary values (0 or 255). Defaults to `True`. nodata (int or float, optional): Overwrite dataset internal nodata value. - unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. - resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. vrt_options (dict, optional): Options to be passed to the rasterio.warp.WarpedVRT class. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + force_binary_mask (bool, optional): Cast returned mask to binary values (0 or 255). Defaults to `True`. + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. post_process (callable, optional): Function to apply on output data and mask values. Returns: - tuple: Data (numpy.ndarray) and Mask (numpy.ndarray) values. + ImageData """ if isinstance(indexes, int): indexes = (indexes,) - if indexes is None: - indexes = non_alpha_indexes(src_dst) - if indexes != src_dst.indexes: - warnings.warn( - "Alpha band was removed from the output data array", AlphaBandWarning - ) + if max_size and width and height: + warnings.warn( + "'max_size' will be ignored with with 'height' and 'width' set.", + UserWarning, + ) - vrt_params = dict(add_alpha=True, resampling=Resampling[resampling_method]) - nodata = nodata if nodata is not None else src_dst.nodata - if nodata is not None: - vrt_params.update(dict(nodata=nodata, add_alpha=False, src_nodata=nodata)) + resampling = Resampling[resampling_method] + dst_crs = dst_crs or src_dst.crs + with contextlib.ExitStack() as ctx: + # Use WarpedVRT when Re-projection or Nodata or User VRT Option (cutline) + if (dst_crs != src_dst.crs) or nodata is not None or vrt_options: + vrt_params = { + "crs": dst_crs, + "add_alpha": True, + "resampling": resampling, + } - if has_alpha_band(src_dst): - vrt_params.update(dict(add_alpha=False)) + nodata = nodata if nodata is not None else src_dst.nodata + if nodata is not None: + vrt_params.update( + {"nodata": nodata, "add_alpha": False, "src_nodata": nodata} + ) - if vrt_options: - vrt_params.update(vrt_options) + if has_alpha_band(src_dst): + vrt_params.update({"add_alpha": False}) - with WarpedVRT(src_dst, **vrt_params) as vrt: - if ColorInterp.alpha in vrt.colorinterp: - indexes = tuple(indexes) + (vrt.colorinterp.index(ColorInterp.alpha) + 1,) - data = vrt.read( - indexes=indexes, + if vrt_options: + vrt_params.update(**vrt_options) + + # TODO: Check if we fetch the Overviews when not using transform + dataset = ctx.enter_context(WarpedVRT(src_dst, **vrt_params)) + + else: + dataset = src_dst + + if max_size and not (width and height): + height, width = _get_width_height(max_size, dataset.height, dataset.width) + + if indexes is None: + indexes = non_alpha_indexes(dataset) + + boundless = False + if window: + if isinstance(window, tuple): + window = windows.Window.from_slices( + *window, height=dataset.height, width=dataset.width, boundless=True + ) + + (row_start, row_stop), (col_start, col_stop) = window.toranges() + if ( + min(col_start, row_start) < 0 + or row_stop >= dataset.width + or col_stop >= dataset.height + ): + boundless = True + + if ColorInterp.alpha in dataset.colorinterp: + # If dataset has an alpha band we need to get the mask using the alpha band index + # and then split the data and mask values + alpha_idx = dataset.colorinterp.index(ColorInterp.alpha) + 1 + idx = tuple(indexes) + (alpha_idx,) + data = dataset.read( + indexes=idx, window=window, - out_shape=(len(indexes), height, width) if height and width else None, - resampling=Resampling[resampling_method], + out_shape=(len(idx), height, width) if height and width else None, + resampling=resampling, + boundless=boundless, ) data, mask = data[0:-1], data[-1].astype("uint8") else: - data = vrt.read( + data = dataset.read( indexes=indexes, window=window, out_shape=(len(indexes), height, width) if height and width else None, - resampling=Resampling[resampling_method], + resampling=resampling, + boundless=boundless, ) - mask = vrt.dataset_mask( + mask = dataset.dataset_mask( window=window, out_shape=(height, width) if height and width else None, - resampling=Resampling[resampling_method], + resampling=resampling, + boundless=boundless, ) - if force_binary_mask: - mask = numpy.where(mask != 0, numpy.uint8(255), numpy.uint8(0)) + stats = [] + for ix in indexes: + tags = dataset.tags(ix) + if all( + stat in tags for stat in ["STATISTICS_MINIMUM", "STATISTICS_MAXIMUM"] + ): + stat_min = float(tags.get("STATISTICS_MINIMUM")) + stat_max = float(tags.get("STATISTICS_MAXIMUM")) + stats.append((stat_min, stat_max)) + + # We only add dataset statistics if we have them for all the indexes + dataset_statistics = stats if len(stats) == len(indexes) else None - if unscale: - data = data.astype("float32", casting="unsafe") - numpy.multiply(data, src_dst.scales[0], out=data, casting="unsafe") - numpy.add(data, src_dst.offsets[0], out=data, casting="unsafe") + if force_binary_mask: + mask = numpy.where(mask != 0, numpy.uint8(255), numpy.uint8(0)) - if post_process: - data, mask = post_process(data, mask) + if unscale: + data = data.astype("float32", casting="unsafe") + numpy.multiply(data, dataset.scales[0], out=data, casting="unsafe") + numpy.add(data, dataset.offsets[0], out=data, casting="unsafe") - return data, mask + if post_process: + data, mask = post_process(data, mask) + out_bounds = ( + windows.bounds(window, dataset.transform) if window else dataset.bounds + ) + + img = ImageData( + data, + mask, + bounds=out_bounds, + crs=dataset.crs, + band_names=[f"b{idx}" for idx in indexes], + dataset_statistics=dataset_statistics, + ) + return img + + +# flake8: noqa: C901 def part( src_dst: Union[DatasetReader, DatasetWriter, WarpedVRT], bounds: BBox, height: Optional[int] = None, width: Optional[int] = None, - padding: int = 0, + max_size: Optional[int] = None, dst_crs: Optional[CRS] = None, bounds_crs: Optional[CRS] = None, + indexes: Optional[Indexes] = None, minimum_overlap: Optional[float] = None, + padding: Optional[int] = None, + buffer: Optional[float] = None, + force_binary_mask: bool = True, + nodata: Optional[NoData] = None, vrt_options: Optional[Dict] = None, - max_size: Optional[int] = None, - **kwargs: Any, -) -> DataMaskType: + resampling_method: Resampling = "nearest", + unscale: bool = False, + post_process: Optional[ + Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] + ] = None, +) -> ImageData: """Read part of a dataset. Args: src_dst (rasterio.io.DatasetReader or rasterio.io.DatasetWriter or rasterio.vrt.WarpedVRT): Rasterio dataset. bounds (tuple): Output bounds (left, bottom, right, top). By default the coordinates are considered to be in either the dataset CRS or in the `dst_crs` if set. Use `bounds_crs` to set a specific CRS. - height (int, optional): Output height of the array. - width (int, optional): Output width of the array. - padding (int, optional): Padding to apply to each edge of the tile when retrieving data to assist in reducing resampling artefacts along edges. Defaults to `0`. + height (int, optional): Output height of the image. + width (int, optional): Output width of the image. + max_size (int, optional): Limit output size image if not width and height. dst_crs (rasterio.crs.CRS, optional): Target coordinate reference system. bounds_crs (rasterio.crs.CRS, optional): Overwrite bounds Coordinate Reference System. + indexes (sequence of int or int, optional): Band indexes. minimum_overlap (float, optional): Minimum % overlap for which to raise an error with dataset not covering enough of the tile. + padding (int, optional): Padding to apply to each bbox edge. Helps reduce resampling artefacts along edges. Defaults to `0`. + buffer (float, optional): Buffer to apply to each bbox edge. Defaults to `0.`. + nodata (int or float, optional): Overwrite dataset internal nodata value. vrt_options (dict, optional): Options to be passed to the rasterio.warp.WarpedVRT class. - max_size (int, optional): Limit output size array if not width and height. - kwargs (optional): Additional options to forward to `rio_tiler.reader.read`. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + force_binary_mask (bool, optional): Cast returned mask to binary values (0 or 255). Defaults to `True`. + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. + post_process (callable, optional): Function to apply on output data and mask values. Returns: - tuple: Data (numpy.ndarray) and Mask (numpy.ndarray) values. + ImageData """ - if not dst_crs: - dst_crs = src_dst.crs - if max_size and width and height: warnings.warn( "'max_size' will be ignored with with 'height' and 'width' set.", UserWarning, ) + if buffer and buffer % 0.5: + raise InvalidBufferSize( + "`buffer` must be a multiple of `0.5` (e.g: 0.5, 1, 1.5, ...)." + ) + + padding = padding or 0 + dst_crs = dst_crs or src_dst.crs if bounds_crs: bounds = transform_bounds(bounds_crs, dst_crs, *bounds, densify_pts=21) @@ -177,106 +322,126 @@ def part( "Dataset covers less than {:.0f}% of tile".format(cover_ratio * 100) ) - vrt_transform, vrt_width, vrt_height = get_vrt_transform( - src_dst, bounds, height, width, dst_crs=dst_crs - ) + # Use WarpedVRT when Re-projection or Nodata or User VRT Option (cutline) + if (dst_crs != src_dst.crs) or nodata is not None or vrt_options: + window = None + vrt_transform, vrt_width, vrt_height = get_vrt_transform( + src_dst, + bounds, + height=height, + width=width, + dst_crs=dst_crs, + ) - window = windows.Window(col_off=0, row_off=0, width=vrt_width, height=vrt_height) + if max_size and not (width and height): + height, width = _get_width_height(max_size, vrt_height, vrt_width) - if max_size and not (width and height): - if max(vrt_width, vrt_height) > max_size: - ratio = vrt_height / vrt_width - if ratio > 1: - height = max_size - width = math.ceil(height / ratio) - else: - width = max_size - height = math.ceil(width * ratio) - - out_height = height or vrt_height - out_width = width or vrt_width - if padding > 0 and not is_aligned(src_dst, bounds, out_height, out_width, dst_crs): - vrt_transform = vrt_transform * Affine.translation(-padding, -padding) - orig_vrt_height = vrt_height - orig_vrt_width = vrt_width - vrt_height = vrt_height + 2 * padding - vrt_width = vrt_width + 2 * padding - window = windows.Window( - col_off=padding, - row_off=padding, - width=orig_vrt_width, - height=orig_vrt_height, - ) + height = height or vrt_height + width = width or vrt_width + + if buffer: + bounds, height, width = _apply_buffer(buffer, bounds, height, width) + # re-calculate the transform given the new bounds, height and width + vrt_transform, vrt_width, vrt_height = get_vrt_transform( + src_dst, bounds, height, width, dst_crs=dst_crs + ) - vrt_options = vrt_options or {} - vrt_options.update( - { + if padding > 0 and not is_aligned(src_dst, bounds, bounds_crs=dst_crs): + vrt_transform = vrt_transform * Affine.translation(-padding, -padding) + window = windows.Window( + col_off=padding, row_off=padding, width=vrt_width, height=vrt_height + ) + vrt_height = vrt_height + 2 * padding + vrt_width = vrt_width + 2 * padding + + vrt_params = { "crs": dst_crs, "transform": vrt_transform, "width": vrt_width, "height": vrt_height, } - ) + if vrt_options: + vrt_params.update(**vrt_options) + + return read( + src_dst, + indexes=indexes, + width=width, + height=height, + window=window, + nodata=nodata, + vrt_options=vrt_params, + resampling_method=resampling_method, + force_binary_mask=force_binary_mask, + unscale=unscale, + post_process=post_process, + ) + + # else no re-projection needed + window = windows.from_bounds(*bounds, transform=src_dst.transform) + if max_size and not (width and height): + height, width = _get_width_height( + max_size, round(window.height), round(window.width) + ) + + height = height or round(window.height) + width = width or round(window.width) + + if buffer: + bounds, height, width = _apply_buffer(buffer, bounds, height, width) + window = windows.from_bounds(*bounds, transform=src_dst.transform) + + if padding > 0 and not is_aligned(src_dst, bounds, bounds_crs=dst_crs): + # For Padding we also use the buffer approach for non-VRT dataset + pad_bounds, height, width = _apply_buffer(padding, bounds, height, width) + window = windows.from_bounds(*pad_bounds, transform=src_dst.transform) + + img = read( + src_dst, + indexes=indexes, + width=width, + height=height, + window=window, + resampling_method=resampling_method, + force_binary_mask=force_binary_mask, + unscale=unscale, + post_process=post_process, + ) + return ImageData( + data=img.data[:, padding:-padding, padding:-padding], + mask=img.mask[padding:-padding, padding:-padding], + bounds=bounds, + crs=img.crs, + band_names=img.band_names, + dataset_statistics=img.dataset_statistics, + ) return read( src_dst, - out_height, - out_width, + indexes=indexes, + width=width, + height=height, window=window, - vrt_options=vrt_options, - **kwargs, + resampling_method=resampling_method, + force_binary_mask=force_binary_mask, + unscale=unscale, + post_process=post_process, ) -def preview( - src_dst: Union[DatasetReader, DatasetWriter, WarpedVRT], - max_size: int = 1024, - height: int = None, - width: int = None, - **kwargs: Any, -) -> DataMaskType: - """Read decimated version of a dataset. - - Args: - src_dst (rasterio.io.DatasetReader or rasterio.io.DatasetWriter or rasterio.vrt.WarpedVRT): Rasterio dataset. - max_size (int, optional): Limit output size array if not width and height. Defaults to `1024`. - height (int, optional): Output height of the array. - width (int, optional): Output width of the array. - kwargs (optional): Additional options to forward to `rio_tiler.reader.read`. - - Returns: - tuple: Data (numpy.ndarray) and Mask (numpy.ndarray) values. - - """ - if not height and not width: - if max(src_dst.height, src_dst.width) < max_size: - height, width = src_dst.height, src_dst.width - else: - ratio = src_dst.height / src_dst.width - if ratio > 1: - height = max_size - width = math.ceil(height / ratio) - else: - width = max_size - height = math.ceil(width * ratio) - - return read(src_dst, height, width, **kwargs) - - def point( src_dst: Union[DatasetReader, DatasetWriter, WarpedVRT], coordinates: Tuple[float, float], indexes: Optional[Indexes] = None, coord_crs: CRS = WGS84_CRS, - masked: bool = True, nodata: Optional[NoData] = None, - unscale: bool = False, - resampling_method: Resampling = "nearest", vrt_options: Optional[Dict] = None, + resampling_method: Resampling = "nearest", + unscale: bool = False, post_process: Optional[ Callable[[numpy.ndarray, numpy.ndarray], DataMaskType] ] = None, -) -> List: +) -> PointData: """Read a pixel value for a point. Args: @@ -284,55 +449,75 @@ def point( coordinates (tuple): Coordinates in form of (X, Y). indexes (sequence of int or int, optional): Band indexes. coord_crs (rasterio.crs.CRS, optional): Coordinate Reference System of the input coords. Defaults to `epsg:4326`. - masked (bool): Mask samples that fall outside the extent of the dataset. Defaults to `True`. nodata (int or float, optional): Overwrite dataset internal nodata value. - unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. - resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. vrt_options (dict, optional): Options to be passed to the rasterio.warp.WarpedVRT class. + resampling_method (rasterio.enums.Resampling, optional): Rasterio's resampling algorithm. Defaults to `nearest`. + unscale (bool, optional): Apply 'scales' and 'offsets' on output data value. Defaults to `False`. post_process (callable, optional): Function to apply on output data and mask values. Returns: - list: Pixel value per band indexes. + PointData """ if isinstance(indexes, int): indexes = (indexes,) - lon, lat = transform_coords( - coord_crs, src_dst.crs, [coordinates[0]], [coordinates[1]] - ) - if not ( - (src_dst.bounds[0] < lon[0] < src_dst.bounds[2]) - and (src_dst.bounds[1] < lat[0] < src_dst.bounds[3]) - ): - raise PointOutsideBounds("Point is outside dataset bounds") - - indexes = indexes if indexes is not None else src_dst.indexes - - vrt_params = dict(add_alpha=True, resampling=Resampling[resampling_method]) - nodata = nodata if nodata is not None else src_dst.nodata - if nodata is not None: - vrt_params.update(dict(nodata=nodata, add_alpha=False, src_nodata=nodata)) - - if has_alpha_band(src_dst): - vrt_params.update(dict(add_alpha=False)) - - if vrt_options: - vrt_params.update(vrt_options) - - with WarpedVRT(src_dst, **vrt_params) as vrt: - values = list(vrt.sample([(lon[0], lat[0])], indexes=indexes, masked=masked))[0] - point_values = values.data - mask = values.mask * 255 if masked else numpy.zeros(point_values.shape) - - if unscale: - point_values = point_values.astype("float32", casting="unsafe") - numpy.multiply( - point_values, src_dst.scales[0], out=point_values, casting="unsafe" - ) - numpy.add(point_values, src_dst.offsets[0], out=point_values, casting="unsafe") + with contextlib.ExitStack() as ctx: + # Use WarpedVRT when Re-projection or Nodata or User VRT Option (cutline) + if nodata is not None or vrt_options: + vrt_params = { + "add_alpha": True, + "resampling": Resampling[resampling_method], + } + nodata = nodata if nodata is not None else src_dst.nodata + if nodata is not None: + vrt_params.update( + {"nodata": nodata, "add_alpha": False, "src_nodata": nodata} + ) + + if has_alpha_band(src_dst): + vrt_params.update({"add_alpha": False}) - if post_process: - point_values, _ = post_process(point_values, mask) + if vrt_options: + vrt_params.update(**vrt_options) + + dataset = ctx.enter_context(WarpedVRT(src_dst, **vrt_params)) + + else: + dataset = src_dst + + lon, lat = transform_coords( + coord_crs, dataset.crs, [coordinates[0]], [coordinates[1]] + ) + if not ( + (dataset.bounds[0] < lon[0] < dataset.bounds[2]) + and (dataset.bounds[1] < lat[0] < dataset.bounds[3]) + ): + raise PointOutsideBounds("Point is outside dataset bounds") + + if indexes is None: + indexes = non_alpha_indexes(dataset) + + values = list(dataset.sample([(lon[0], lat[0])], indexes=indexes, masked=True))[ + 0 + ] + data = values.data + mask = ~values.mask * numpy.uint8(255) + + if unscale: + data = data.astype("float32", casting="unsafe") + numpy.multiply(data, dataset.scales[0], out=data, casting="unsafe") + numpy.add(data, dataset.offsets[0], out=data, casting="unsafe") + + if post_process: + data, _ = post_process(data, mask) + + pts = PointData( + data, + mask, + coordinates=coordinates, + crs=coord_crs, + band_names=[f"b{idx}" for idx in indexes], + ) - return point_values.tolist() + return pts diff --git a/rio_tiler/tasks.py b/rio_tiler/tasks.py index 87530b12..e8484f76 100644 --- a/rio_tiler/tasks.py +++ b/rio_tiler/tasks.py @@ -4,9 +4,9 @@ from functools import partial from typing import Any, Callable, Dict, Generator, Optional, Sequence, Tuple, Union -from .constants import MAX_THREADS -from .logger import logger -from .models import ImageData +from rio_tiler.constants import MAX_THREADS +from rio_tiler.logger import logger +from rio_tiler.models import ImageData, PointData TaskType = Sequence[Tuple[Union[futures.Future, Callable], Any]] @@ -72,6 +72,21 @@ def multi_arrays( ) +def multi_points( + asset_list: Sequence, + reader: Callable[..., PointData], + *args: Any, + threads: int = MAX_THREADS, + allowed_exceptions: Optional[Tuple] = None, + **kwargs: Any, +) -> PointData: + """Merge points returned from tasks.""" + tasks = create_tasks(reader, asset_list, threads, *args, **kwargs) + return PointData.create_from_list( + [data for data, _ in filter_tasks(tasks, allowed_exceptions=allowed_exceptions)] + ) + + def multi_values( asset_list: Sequence, reader: Callable, diff --git a/rio_tiler/utils.py b/rio_tiler/utils.py index 0a4ae31b..09700025 100644 --- a/rio_tiler/utils.py +++ b/rio_tiler/utils.py @@ -20,11 +20,11 @@ from rasterio.vrt import WarpedVRT from rasterio.warp import calculate_default_transform, transform_geom -from .colormap import apply_cmap -from .constants import WEB_MERCATOR_CRS -from .errors import RioTilerError -from .expression import get_expression_blocks -from .types import BBox, ColorMapType, IntervalTuple +from rio_tiler.colormap import apply_cmap +from rio_tiler.constants import WEB_MERCATOR_CRS +from rio_tiler.errors import RioTilerError +from rio_tiler.expression import get_expression_blocks +from rio_tiler.types import BBox, ColorMapType, IntervalTuple def _chunks(my_list: Sequence, chuck_size: int) -> Generator[Sequence, None, None]: @@ -67,6 +67,10 @@ def get_bands_names( count: Optional[int] = None, ) -> List[str]: """Define bands names based on expression, indexes or band count.""" + warnings.warn( + "`get_bands_names` is deprecated, and will be removed in rio-tiler 4.0`.", + DeprecationWarning, + ) if expression: return get_expression_blocks(expression) @@ -272,9 +276,7 @@ def get_vrt_transform( # If bounds window is aligned with the dataset internal tile we align the bounds with the pixels. # This is to limit the number of internal block fetched. - if _requested_tile_aligned_with_internal_tile( - src_dst, bounds, height, width, dst_crs - ): + if _requested_tile_aligned_with_internal_tile(src_dst, bounds, bounds_crs=dst_crs): # Get Window for the input bounds # e.g Window(col_off=17920.0, row_off=11007.999999999998, width=255.99999999999636, height=256.0000000000018) col_off, row_off, w, h = windows.from_bounds( @@ -374,16 +376,14 @@ def linear_rescale( """ imin, imax = in_range omin, omax = out_range - image = numpy.clip(image, imin, imax) - imin - image = image / numpy.float64(imax - imin) - return image * (omax - omin) + omin + im = numpy.clip(image, imin, imax) - imin + im = im / numpy.float64(imax - imin) + return im * (omax - omin) + omin def _requested_tile_aligned_with_internal_tile( src_dst: Union[DatasetReader, DatasetWriter, WarpedVRT], bounds: BBox, - height: Optional[int] = None, - width: Optional[int] = None, bounds_crs: CRS = WEB_MERCATOR_CRS, ) -> bool: """Check if tile is aligned with internal tiles.""" @@ -461,22 +461,20 @@ def render( elif img_format == "NPY": # If mask is not None we add it as the last band if mask is not None: - mask = numpy.expand_dims(mask, axis=0) - data = numpy.concatenate((data, mask)) + m = numpy.expand_dims(mask, axis=0) + data = numpy.concatenate((data, m)) - bio = BytesIO() - numpy.save(bio, data) - bio.seek(0) - return bio.getvalue() + with BytesIO() as bio: + numpy.save(bio, data) + return bio.getvalue() elif img_format == "NPZ": - bio = BytesIO() - if mask is not None: - numpy.savez_compressed(bio, data=data, mask=mask) - else: - numpy.savez_compressed(bio, data=data) - bio.seek(0) - return bio.getvalue() + with BytesIO() as bio: + if mask is not None: + numpy.savez_compressed(bio, data=data, mask=mask) + else: + numpy.savez_compressed(bio, data=data) + return bio.getvalue() count, height, width = data.shape @@ -659,3 +657,13 @@ def resize_array( indexes=indexes, resampling=Resampling[resampling_method], ) + + +def normalize_bounds(bounds: BBox) -> BBox: + """Return BBox in correct minx, miny, maxx, maxy order.""" + return ( + min(bounds[0], bounds[2]), + min(bounds[1], bounds[3]), + max(bounds[0], bounds[2]), + max(bounds[1], bounds[3]), + ) diff --git a/tests/benchmarks/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py index 1f527a98..bcddfbd9 100644 --- a/tests/benchmarks/test_benchmarks.py +++ b/tests/benchmarks/test_benchmarks.py @@ -5,7 +5,7 @@ import pytest import rasterio -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from . import benchmark_dataset, benchmark_tiles @@ -16,7 +16,7 @@ def read_tile(src_path, tile): """Benchmark rio-tiler.utils._tile_read.""" # We make sure to not store things in cache. with rasterio.Env(GDAL_CACHEMAX=0, NUM_THREADS="all"): - with COGReader(src_path, minzoom=0, maxzoom=24) as cog: + with Reader(src_path) as cog: return cog.tile(*tile) diff --git a/tests/fixtures/blue.tif b/tests/fixtures/blue.tif index ab527cb4..8f699011 100644 Binary files a/tests/fixtures/blue.tif and b/tests/fixtures/blue.tif differ diff --git a/tests/fixtures/cog_rgb.tif b/tests/fixtures/cog_rgb.tif new file mode 100644 index 00000000..8529b1b3 Binary files /dev/null and b/tests/fixtures/cog_rgb.tif differ diff --git a/tests/fixtures/green.tif b/tests/fixtures/green.tif index 002ed054..f7c9e16f 100644 Binary files a/tests/fixtures/green.tif and b/tests/fixtures/green.tif differ diff --git a/tests/fixtures/no_geo.jpg b/tests/fixtures/no_geo.jpg new file mode 100644 index 00000000..be29ec30 Binary files /dev/null and b/tests/fixtures/no_geo.jpg differ diff --git a/tests/fixtures/red.tif b/tests/fixtures/red.tif index 933bbc0b..bc527028 100644 Binary files a/tests/fixtures/red.tif and b/tests/fixtures/red.tif differ diff --git a/tests/fixtures/scene_b1.tif b/tests/fixtures/scene_band1.tif similarity index 100% rename from tests/fixtures/scene_b1.tif rename to tests/fixtures/scene_band1.tif diff --git a/tests/fixtures/scene_b2.tif b/tests/fixtures/scene_band2.tif similarity index 100% rename from tests/fixtures/scene_b2.tif rename to tests/fixtures/scene_band2.tif diff --git a/tests/test_io_MultiBand.py b/tests/test_io_MultiBand.py index 298c83cb..16a159bc 100644 --- a/tests/test_io_MultiBand.py +++ b/tests/test_io_MultiBand.py @@ -10,7 +10,7 @@ from rio_tiler.constants import WEB_MERCATOR_TMS from rio_tiler.errors import ExpressionMixingWarning, MissingBands -from rio_tiler.io import BaseReader, COGReader, MultiBandReader +from rio_tiler.io import BaseReader, MultiBandReader, Reader from rio_tiler.models import BandStatistics PREFIX = os.path.join(os.path.dirname(__file__), "fixtures") @@ -22,9 +22,20 @@ class BandFileReader(MultiBandReader): input: str = attr.ib() tms: morecantile.TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) + + reader: Type[BaseReader] = attr.ib(init=False, default=Reader) reader_options: Dict = attr.ib(factory=dict) - reader: Type[BaseReader] = attr.ib(init=False, default=COGReader) + minzoom: int = attr.ib() + maxzoom: int = attr.ib() + + @minzoom.default + def _minzoom(self): + return self.tms.minzoom + + @maxzoom.default + def _maxzoom(self): + return self.tms.maxzoom def __attrs_post_init__(self): """Parse Sceneid and get grid bounds.""" @@ -48,86 +59,98 @@ def _get_band_url(self, band: str) -> str: def test_MultiBandReader(): """Should work as expected.""" with BandFileReader(PREFIX) as cog: - assert cog.bands == ["b1", "b2"] + assert cog.bands == ["band1", "band2"] assert cog.minzoom is not None assert cog.maxzoom is not None assert cog.bounds assert cog.bounds assert cog.crs - assert sorted(cog.parse_expression("b1/b2")) == ["b1", "b2"] + assert sorted(cog.parse_expression("band1/band2")) == ["band1", "band2"] with pytest.warns(UserWarning): meta = cog.info() - assert meta.band_descriptions == [("b1", ""), ("b2", "")] + assert meta.band_descriptions == [("band1", ""), ("band2", "")] - meta = cog.info(bands="b1") - assert meta.band_descriptions == [("b1", "")] + meta = cog.info(bands="band1") + assert meta.band_descriptions == [("band1", "")] - meta = cog.info(bands=("b1", "b2")) - assert meta.band_descriptions == [("b1", ""), ("b2", "")] + meta = cog.info(bands=("band1", "band2")) + assert meta.band_descriptions == [("band1", ""), ("band2", "")] with pytest.warns(UserWarning): stats = cog.statistics() - assert stats["b1"] - assert stats["b2"] + assert stats["band1"] + assert stats["band2"] - stats = cog.statistics(bands="b1") - assert "b1" in stats - assert isinstance(stats["b1"], BandStatistics) + stats = cog.statistics(bands="band1") + assert "band1" in stats + assert isinstance(stats["band1"], BandStatistics) - stats = cog.statistics(bands=("b1", "b2")) - assert stats["b1"] - assert stats["b2"] + stats = cog.statistics(bands=("band1", "band2")) + assert stats["band1"] + assert stats["band2"] - stats = cog.statistics(expression="b1;b1+b2;b1-100") - assert stats["b1"] - assert stats["b1+b2"] - assert stats["b1-100"] + stats = cog.statistics(expression="band1;band1+band2;band1-100") + assert stats["band1"] + assert stats["band1+band2"] + assert stats["band1-100"] with pytest.raises(MissingBands): cog.tile(238, 218, 9) - tile = cog.tile(238, 218, 9, bands="b1") + tile = cog.tile(238, 218, 9, bands="band1") assert tile.data.shape == (1, 256, 256) - assert tile.band_names == ["b1"] + assert tile.band_names == ["band1"] with pytest.warns(ExpressionMixingWarning): - tile = cog.tile(238, 218, 9, bands="b1", expression="b1*2") + tile = cog.tile(238, 218, 9, bands="band1", expression="band1*2") assert tile.data.shape == (1, 256, 256) - assert tile.band_names == ["b1*2"] + assert tile.band_names == ["band1*2"] with pytest.raises(MissingBands): cog.part((-11.5, 24.5, -11.0, 25.0)) - tile = cog.part((-11.5, 24.5, -11.0, 25.0), bands="b1") + tile = cog.part((-11.5, 24.5, -11.0, 25.0), bands="band1") assert tile.data.any() - assert tile.band_names == ["b1"] + assert tile.band_names == ["band1"] with pytest.warns(ExpressionMixingWarning): - tile = cog.part((-11.5, 24.5, -11.0, 25.0), bands="b1", expression="b1*2") + tile = cog.part( + (-11.5, 24.5, -11.0, 25.0), bands="band1", expression="band1*2" + ) assert tile.data.any() - assert tile.band_names == ["b1*2"] + assert tile.band_names == ["band1*2"] with pytest.raises(MissingBands): cog.preview() - tile = cog.preview(bands="b1") + tile = cog.preview(bands="band1") assert tile.data.any() - assert tile.band_names == ["b1"] + assert tile.band_names == ["band1"] with pytest.warns(ExpressionMixingWarning): - tile = cog.preview(bands="b1", expression="b1*2") + tile = cog.preview(bands="band1", expression="band1*2") assert tile.data.any() - assert tile.band_names == ["b1*2"] + assert tile.band_names == ["band1*2"] with pytest.raises(MissingBands): cog.point(-11.5, 24.5) - assert cog.point(-11.5, 24.5, bands="b1") + pt = cog.point(-11.5, 24.5, bands="band1") + assert len(pt.data) == 1 + assert pt.band_names == ["band1"] + + pt = cog.point(-11.5, 24.5, bands=("band1", "band2")) + assert len(pt.data) == 2 + assert pt.band_names == ["band1", "band2"] + + pt = cog.point(-11.5, 24.5, expression="band1/band2") + assert len(pt.data) == 1 + assert pt.band_names == ["band1/band2"] with pytest.warns(ExpressionMixingWarning): - assert cog.point(-11.5, 24.5, bands="b1", expression="b1*2") + assert cog.point(-11.5, 24.5, bands="band1", expression="band1*2") feat = { "type": "Feature", @@ -151,11 +174,11 @@ def test_MultiBandReader(): with pytest.raises(MissingBands): cog.feature(feat) - img = cog.feature(feat, bands="b1") + img = cog.feature(feat, bands="band1") assert img.data.any() assert not img.mask.all() - assert img.band_names == ["b1"] + assert img.band_names == ["band1"] with pytest.warns(ExpressionMixingWarning): - img = cog.feature(feat, bands="b1", expression="b1*2") - assert img.band_names == ["b1*2"] + img = cog.feature(feat, bands="band1", expression="band1*2") + assert img.band_names == ["band1*2"] diff --git a/tests/test_io_async.py b/tests/test_io_async.py deleted file mode 100644 index 3f982cb5..00000000 --- a/tests/test_io_async.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Test Async BaseClass.""" - -import asyncio -import functools -import os -import typing -from typing import Any, Coroutine, Dict, List, Type - -import attr -import morecantile -import pytest - -from rio_tiler.constants import WEB_MERCATOR_TMS -from rio_tiler.io import AsyncBaseReader, COGReader -from rio_tiler.models import BandStatistics, ImageData, Info -from rio_tiler.types import BBox - -try: - import contextvars # Python 3.7+ only or via contextvars backport. -except ImportError: # pragma: no cover - contextvars = None # type: ignore - -T = typing.TypeVar("T") - -PREFIX = os.path.join(os.path.dirname(__file__), "fixtures") -COGEO = os.path.join(PREFIX, "cog_nodata.tif") - - -async def run_in_threadpool( - func: typing.Callable[..., T], *args: typing.Any, **kwargs: typing.Any -) -> T: - """Mock Sync function for Async call.Any - - Code from https://github.com/encode/starlette/blob/master/starlette/concurrency.py - """ - loop = asyncio.get_event_loop() - if contextvars is not None: # pragma: no cover - # Ensure we run in the same context - child = functools.partial(func, *args, **kwargs) - context = contextvars.copy_context() - func = context.run - args = (child,) - elif kwargs: # pragma: no cover - # loop.run_in_executor doesn't accept 'kwargs', so bind them in here - func = functools.partial(func, **kwargs) - return await loop.run_in_executor(None, func, *args) - - -@attr.s -class AsyncCOGReader(AsyncBaseReader): - - input: Type[COGReader] = attr.ib() - tms: morecantile.TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS) - - def __attrs_post_init__(self): - """Update dataset info.""" - self.bounds = self.input.bounds - self.crs = self.input.crs - self.minzoom = self.input.minzoom - self.maxzoom = self.input.maxzoom - - async def info(self) -> Coroutine[Any, Any, Info]: - """Return Dataset's info.""" - return await run_in_threadpool(self.input.info) # type: ignore - - async def statistics( - self, **kwargs: Any - ) -> Coroutine[Any, Any, Dict[str, BandStatistics]]: - """Return Dataset's statistics.""" - return await run_in_threadpool(self.input.statistics, **kwargs) # type: ignore - - async def tile( - self, tile_x: int, tile_y: int, tile_z: int, **kwargs: Any - ) -> Coroutine[Any, Any, ImageData]: - """Read a Map tile from the Dataset.""" - return await run_in_threadpool( - self.input.tile, tile_x, tile_y, tile_z, **kwargs # type: ignore - ) - - async def part(self, bbox: BBox, **kwargs: Any) -> Coroutine[Any, Any, ImageData]: - """Read a Part of a Dataset.""" - return await run_in_threadpool(self.input.part, bbox, **kwargs) # type: ignore - - async def preview(self, **kwargs: Any) -> Coroutine[Any, Any, ImageData]: - """Return a preview of a Dataset.""" - return await run_in_threadpool(self.input.preview, **kwargs) # type: ignore - - async def point( - self, lon: float, lat: float, **kwargs: Any - ) -> Coroutine[Any, Any, List]: - """Read a value from a Dataset.""" - return await run_in_threadpool(self.input.point, lon, lat, **kwargs) # type: ignore - - async def feature( - self, shape: Dict, **kwargs: Any - ) -> Coroutine[Any, Any, ImageData]: - """Read a Dataset for a GeoJSON feature""" - return await run_in_threadpool(self.input.feature, shape, **kwargs) # type: ignore - - -@pytest.mark.asyncio -async def test_async(): - dataset = COGReader(COGEO) - - async with AsyncCOGReader(dataset) as cog: - info = await cog.info() - assert info == dataset.info() - - assert cog.minzoom == 5 - assert cog.maxzoom == 9 - - stat = await cog.statistics() - assert stat == dataset.statistics() - - data, mask = await cog.tile(43, 24, 7) - assert data.shape == (1, 256, 256) - assert mask.all() - - lon = -56.624124590533825 - lat = 73.52687881825946 - pts = await cog.point(lon, lat) - assert len(pts) == 1 - - bbox = ( - -56.624124590533825, - 73.50183615350426, - -56.530950796449005, - 73.52687881825946, - ) - data, mask = await cog.part(bbox) - assert data.shape == (1, 11, 40) - - data, mask = await cog.preview(max_size=128) - assert data.shape == (1, 128, 128) - - feature = { - "type": "Feature", - "properties": {}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-56.4697265625, 74.17307693616263], - [-57.667236328125, 73.53462847039683], - [-57.59033203125, 73.13451013251789], - [-56.195068359375, 72.94865294642922], - [-54.964599609375, 72.96797135377102], - [-53.887939453125, 73.84623016391944], - [-53.97583007812499, 74.0165183926664], - [-54.73388671875, 74.23289305339864], - [-55.54687499999999, 74.2269213699517], - [-56.129150390625, 74.21497138945001], - [-56.2060546875, 74.21198251594369], - [-56.4697265625, 74.17307693616263], - ] - ], - }, - } - - img = await cog.feature(feature, max_size=1024) - assert img.data.shape == (1, 348, 1024) diff --git a/tests/test_io_image.py b/tests/test_io_image.py new file mode 100644 index 00000000..d9f14c7d --- /dev/null +++ b/tests/test_io_image.py @@ -0,0 +1,163 @@ +import os +import warnings + +import numpy +import pytest +from rasterio.errors import NotGeoreferencedWarning + +from rio_tiler.errors import PointOutsideBounds, TileOutsideBounds +from rio_tiler.io.rasterio import ImageReader + +PREFIX = os.path.join(os.path.dirname(__file__), "fixtures") +NO_GEO = os.path.join(PREFIX, "no_geo.jpg") +GEO = os.path.join(PREFIX, "cog_nonearth.tif") + + +def test_non_geo_image(): + """Test COGReader usage with Non-Geo Images.""" + with pytest.warns() as w: + with ImageReader(NO_GEO) as src: + assert src.minzoom == 0 + assert src.maxzoom == 3 + assert len(w) == 1 + assert issubclass(w[0].category, NotGeoreferencedWarning) + + with warnings.catch_warnings(): + with ImageReader(NO_GEO) as src: + assert list(src.tms.xy_bounds(0, 0, 3)) == [0, 256, 256, 0] + assert list(src.tms.xy_bounds(0, 0, 2)) == [0, 512, 512, 0] + assert list(src.tms.xy_bounds(0, 0, 1)) == [0, 1024, 1024, 0] + assert list(src.tms.xy_bounds(0, 0, 0)) == [0, 2048, 2048, 0] + + img = src.tile(0, 0, 3) + assert img.mask.all() + + # Make sure no resampling was done at full resolution + data = src.dataset.read(window=((0, 256), (0, 256))) + numpy.testing.assert_array_equal(data, img.data) + + # Tile at zoom 0 should have masked part + img = src.tile(0, 0, 0) + assert not img.mask.all() + + with pytest.raises(TileOutsideBounds): + max_x_tile = src.dataset.width // 256 + 1 + max_y_tile = src.dataset.height // 256 + 1 + src.tile(max_x_tile, max_y_tile, 3) + + img = src.part((0, 256, 256, 0)) + data = src.dataset.read(window=((0, 256), (0, 256))) + numpy.testing.assert_array_equal(data, img.data) + + img = src.preview() + assert img.width == 1024 + assert img.height == 1024 + + pt = src.point(0, 0) + assert len(pt.mask) == 1 + assert pt.mask[0] == 255 + data = list(src.dataset.sample([(0, 0)]))[0] + numpy.testing.assert_array_equal(pt.data, data) + + pt = src.point(1999, 1999) + data = list(src.dataset.sample([(1999, 1999)]))[0] + numpy.testing.assert_array_equal(pt.data, data) + + with pytest.raises(PointOutsideBounds): + src.point(2000, 2000) + + poly = { + "coordinates": [ + [ + [-100.0, -100.0], + [1000.0, 100.0], + [500.0, 1000.0], + [-50.0, 500.0], + [-100.0, -100.0], + ] + ], + "type": "Polygon", + } + im = src.feature(poly) + assert im.data.shape == (3, 1100, 1100) + + +def test_with_geo_image(): + """Test ImageReader usage with Geo Images.""" + with ImageReader(GEO) as src: + assert src.minzoom == 0 + assert src.maxzoom == 2 + + assert list(src.tms.xy_bounds(0, 0, 2)) == [0, 256, 256, 0] + assert list(src.tms.xy_bounds(0, 0, 1)) == [0, 512, 512, 0] + assert list(src.tms.xy_bounds(0, 0, 0)) == [0, 1024, 1024, 0] + + img = src.tile(10, 12, 4) + assert img.mask.all() + # img should keep the geo information from the dataset + assert img.crs == src.dataset.crs + assert img.bounds != list(src.tms.xy_bounds(10, 12, 4)) + + img = src.tile(0, 0, 3) + assert not img.mask.any() + + # Make sure no resampling was done at full resolution + data = src.dataset.read(window=((0, 256), (0, 256))) + numpy.testing.assert_array_equal(data, img.data) + + # Tile at zoom 0 should have masked part + img = src.tile(0, 0, 0) + assert not img.mask.all() + + with pytest.raises(TileOutsideBounds): + max_x_tile = src.dataset.width // 256 + 1 + max_y_tile = src.dataset.height // 256 + 1 + src.tile(max_x_tile, max_y_tile, 2) + + img = src.part((0, 256, 256, 0)) + data = src.dataset.read(window=((0, 256), (0, 256))) + numpy.testing.assert_array_equal(data, img.data) + + img = src.preview() + assert img.width == 921 + assert img.height == 884 + # img should keep the geo information from the dataset + assert img.crs == src.dataset.crs + assert img.bounds != list(src.tms.xy_bounds(10, 12, 4)) + + pt = src.point(0, 0) + # pixel 0,0 is masked + assert len(pt.mask) == 1 + assert pt.mask[0] == 0 + + data = list(src.dataset.sample([(0, 0)]))[0] + numpy.testing.assert_array_equal(pt.data, data) + + pt = src.point(400, 800) + # pixel 400,800 has valid values + assert len(pt.mask) == 1 + assert pt.mask[0] == 255 + + pt = src.point(920, 883) + data = list(src.dataset.sample([(920, 883)]))[0] + numpy.testing.assert_array_equal(pt.data, data) + assert pt.crs == src.dataset.crs + assert pt.coordinates != [920, 883] + + with pytest.raises(PointOutsideBounds): + src.point(2000, 2000) + + poly = { + "coordinates": [ + [ + [-100.0, -100.0], + [1000.0, 100.0], + [500.0, 1000.0], + [-50.0, 500.0], + [-100.0, -100.0], + ] + ], + "type": "Polygon", + } + im = src.feature(poly) + assert im.data.shape == (1, 1100, 1100) diff --git a/tests/test_io_cogeo.py b/tests/test_io_rasterio.py similarity index 73% rename from tests/test_io_cogeo.py rename to tests/test_io_rasterio.py index 8515c101..70a637d5 100644 --- a/tests/test_io_cogeo.py +++ b/tests/test_io_rasterio.py @@ -1,6 +1,7 @@ -"""tests rio_tiler.io.cogeo.COGReader""" +"""tests rio_tiler.io.rasterio.Reader""" import os +import warnings from io import BytesIO from typing import Any, Dict @@ -12,19 +13,18 @@ from morecantile import TileMatrixSet from pyproj import CRS from rasterio import transform -from rasterio.io import DatasetReader, MemoryFile +from rasterio.io import MemoryFile from rasterio.vrt import WarpedVRT from rasterio.warp import transform_bounds from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS from rio_tiler.errors import ( - AlphaBandWarning, ExpressionMixingWarning, - IncorrectTileBuffer, + InvalidBufferSize, NoOverviewWarning, TileOutsideBounds, ) -from rio_tiler.io import COGReader, GCPCOGReader +from rio_tiler.io import Reader from rio_tiler.models import BandStatistics PREFIX = os.path.join(os.path.dirname(__file__), "fixtures") @@ -54,42 +54,29 @@ def test_spatial_info_valid(): """Should work as expected (get spatial info)""" - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: assert not cog.dataset.closed assert cog.bounds assert cog.crs assert cog.minzoom == 5 assert cog.maxzoom == 9 - assert cog.nodata == cog.dataset.nodata assert cog.dataset.closed - cog = COGReader(COG_NODATA) + cog = Reader(COG_NODATA) assert not cog.dataset.closed cog.close() assert cog.dataset.closed - with COGReader(COG_NODATA, minzoom=3) as cog: - assert cog.minzoom == 3 - assert cog.maxzoom == 9 - - with COGReader(COG_NODATA, maxzoom=12) as cog: - assert cog.minzoom == 5 - assert cog.maxzoom == 12 - - with COGReader(COG_NODATA, minzoom=3, maxzoom=12) as cog: - assert cog.minzoom == 3 - assert cog.maxzoom == 12 - def test_bounds_valid(): """Should work as expected (get bounds)""" - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: assert len(cog.bounds) == 4 def test_info_valid(): """Should work as expected (get file info)""" - with COGReader(COG_SCALE) as cog: + with Reader(COG_SCALE) as cog: meta = cog.info() assert meta["scale"] assert meta.scale @@ -101,19 +88,19 @@ def test_info_valid(): assert meta.overviews assert meta.driver - with COGReader(COG_CMAP) as cog: + with Reader(COG_CMAP) as cog: assert cog.colormap meta = cog.info() assert meta["colormap"] assert meta.colormap - with COGReader(COG_NODATA, colormap={1: (0, 0, 0, 0)}) as cog: + with Reader(COG_NODATA, colormap={1: (0, 0, 0, 0)}) as cog: assert cog.colormap meta = cog.info() assert meta.colormap assert meta.nodata_value - with COGReader(COG_TAGS) as cog: + with Reader(COG_TAGS) as cog: meta = cog.info() assert meta.bounds assert meta.minzoom @@ -126,34 +113,34 @@ def test_info_valid(): assert meta.offset assert meta.band_metadata band_meta = meta.band_metadata[0] - assert band_meta[0] == "1" + assert band_meta[0] == "b1" assert "STATISTICS_MAXIMUM" in band_meta[1] - with COGReader(COG_ALPHA) as cog: + with Reader(COG_ALPHA) as cog: meta = cog.info() assert meta.nodata_type == "Alpha" - with COGReader(COG_MASK) as cog: + with Reader(COG_MASK) as cog: meta = cog.info() assert meta.nodata_type == "Mask" - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: meta = cog.info() assert meta.nodata_type == "None" - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: meta = cog.info() assert meta.nodata_type == "Nodata" def test_tile_valid_default(): """Should return a 3 bands array and a full valid mask.""" - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: # Full tile img = cog.tile(43, 24, 7) assert img.data.shape == (1, 256, 256) assert img.mask.all() - assert img.band_names == ["1"] + assert img.band_names == ["b1"] # Validate that Tile and Part gives the same result tile_bounds = WEB_MERCATOR_TMS.xy_bounds(43, 24, 7) @@ -194,12 +181,12 @@ def test_tile_valid_default(): ), ) assert img.data.shape == (2, 256, 256) - assert img.band_names == ["1", "1"] + assert img.band_names == ["b1", "b1"] # We are using a file that is aligned with the grid so no resampling should be involved - with COGReader(COG_WEB) as cog: + with Reader(COG_WEB) as cog: img = cog.tile(147, 182, 9) - img_buffer = cog.tile(147, 182, 9, tile_buffer=10) + img_buffer = cog.tile(147, 182, 9, buffer=10) assert img_buffer.width == 276 assert img_buffer.height == 276 assert not img.bounds == img_buffer.bounds @@ -209,31 +196,31 @@ def test_tile_valid_default(): def test_tile_invalid_bounds(): """Should raise an error with invalid tile.""" with pytest.raises(TileOutsideBounds): - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: cog.tile(38, 24, 7) def test_tile_with_incorrect_float_buffer(): - with pytest.raises(IncorrectTileBuffer): - with COGReader(COGEO) as cog: - cog.tile(43, 24, 7, tile_buffer=0.8) + with pytest.raises(InvalidBufferSize): + with Reader(COGEO) as cog: + cog.tile(43, 24, 7, buffer=0.8) def test_tile_with_int_buffer(): - with COGReader(COGEO) as cog: - data, mask = cog.tile(43, 24, 7, tile_buffer=1) + with Reader(COGEO) as cog: + data, mask = cog.tile(43, 24, 7, buffer=1) assert data.shape == (1, 258, 258) assert mask.all() - with COGReader(COGEO) as cog: - data, mask = cog.tile(43, 24, 7, tile_buffer=0) + with Reader(COGEO) as cog: + data, mask = cog.tile(43, 24, 7, buffer=0) assert data.shape == (1, 256, 256) assert mask.all() def test_tile_with_correct_float_buffer(): - with COGReader(COGEO) as cog: - data, mask = cog.tile(43, 24, 7, tile_buffer=0.5) + with Reader(COGEO) as cog: + data, mask = cog.tile(43, 24, 7, buffer=0.5) assert data.shape == (1, 257, 257) assert mask.all() @@ -242,21 +229,28 @@ def test_point_valid(): """Read point.""" lon = -56.624124590533825 lat = 73.52687881825946 - with COGReader(COG_NODATA) as cog: - pts = cog.point(lon, lat) - assert len(pts) == 1 - - pts = cog.point(lon, lat, expression="b1*2;b1-100") - assert len(pts) == 2 + with Reader(COG_NODATA) as cog: + pt = cog.point(lon, lat) + assert len(pt.data) == 1 + assert len(pt.mask) == 1 + assert pt.band_names == ["b1"] + + pt = cog.point(lon, lat, expression="b1*2;b1-100") + assert len(pt.data) == 2 + assert len(pt.mask) == 1 + assert pt.mask[0] == 255 + assert pt.band_names == ["b1*2", "b1-100"] with pytest.warns(ExpressionMixingWarning): - pts = cog.point(lon, lat, indexes=(1, 2, 3), expression="b1*2") - assert len(pts) == 1 + pt = cog.point(lon, lat, indexes=(1, 2, 3), expression="b1*2") + assert len(pt.data) == 1 + assert pt.band_names == ["b1*2"] - pts = cog.point(lon, lat, indexes=1) - assert len(pts) == 1 + pt = cog.point(lon, lat, indexes=1) + assert len(pt.data) == 1 + assert pt.band_names == ["b1"] - pts = cog.point( + pt = cog.point( lon, lat, indexes=( @@ -264,7 +258,13 @@ def test_point_valid(): 1, ), ) - assert len(pts) == 2 + assert len(pt.data) == 2 + assert pt.band_names == ["b1", "b1"] + + pt = cog.point(-59.53, 74.03, indexes=(1, 1, 1)) + assert len(pt.data) == 3 + assert pt.mask[0] == 0 + assert pt.band_names == ["b1", "b1", "b1"] def test_area_valid(): @@ -275,10 +275,10 @@ def test_area_valid(): -56.530950796449005, 73.52687881825946, ) - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: img = cog.part(bbox) assert img.data.shape == (1, 11, 40) - assert img.band_names == ["1"] + assert img.band_names == ["b1"] data, mask = cog.part(bbox, dst_crs=cog.dataset.crs) assert data.shape == (1, 28, 30) @@ -306,15 +306,15 @@ def test_area_valid(): ), ) assert img.data.shape == (2, 11, 40) - assert img.band_names == ["1", "1"] + assert img.band_names == ["b1", "b1"] def test_preview_valid(): """Read preview.""" - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: img = cog.preview(max_size=128) assert img.data.shape == (1, 128, 128) - assert img.band_names == ["1"] + assert img.band_names == ["b1"] data, mask = cog.preview() assert data.shape == (1, 1024, 1021) @@ -339,29 +339,29 @@ def test_preview_valid(): ), ) assert img.data.shape == (2, 128, 128) - assert img.band_names == ["1", "1"] + assert img.band_names == ["b1", "b1"] def test_statistics(): """tests statistics method.""" - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: stats = cog.statistics() assert len(stats) == 1 - assert isinstance(stats["1"], BandStatistics) - assert stats["1"].percentile_2 - assert stats["1"].percentile_98 + assert isinstance(stats["b1"], BandStatistics) + assert stats["b1"].percentile_2 + assert stats["b1"].percentile_98 - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: stats = cog.statistics(percentiles=[3]) - assert stats["1"].percentile_3 + assert stats["b1"].percentile_3 - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: stats = cog.statistics(percentiles=[3]) - assert stats["1"].percentile_3 + assert stats["b1"].percentile_3 - with COGReader(COG_CMAP) as cog: + with Reader(COG_CMAP) as cog: stats = cog.statistics(categorical=True) - assert stats["1"].histogram[1] == [ + assert stats["b1"].histogram[1] == [ 1.0, 3.0, 4.0, @@ -376,56 +376,56 @@ def test_statistics(): ] stats = cog.statistics(categorical=True, categories=[1, 3]) - assert stats["1"].histogram[1] == [ + assert stats["b1"].histogram[1] == [ 1.0, 3.0, ] # make sure kwargs are passed to `preview` - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: stats = cog.statistics(width=100, height=100, max_size=None) - assert stats["1"].count == 10000.0 + assert stats["b1"].count == 10000.0 # Check results for expression - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: stats = cog.statistics(expression="b1;b1*2") assert stats["b1"] assert stats["b1*2"] assert stats["b1"].min == stats["b1*2"].min / 2 -def test_COGReader_Options(): +def test_Reader_Options(): """Set options in reader.""" - with COGReader(COGEO, nodata=1) as cog: - assert cog.nodata == 1 + with Reader(COGEO, options={"nodata": 1}) as cog: + assert cog.info().nodata_value == 1 + assert cog.info().nodata_type == "Nodata" - with COGReader(COGEO) as cog: - assert not cog.nodata + with Reader(COGEO) as cog: assert cog.info().nodata_type == "None" - with COGReader(COGEO, nodata=1) as cog: + with Reader(COGEO, options={"nodata": 1}) as cog: _, mask = cog.tile(43, 25, 7) assert not mask.all() # read cog using default Nearest - with COGReader(COGEO, nodata=1) as cog: + with Reader(COGEO, options={"nodata": 1}) as cog: data_default, _ = cog.tile(43, 25, 7) # read cog using bilinear - with COGReader(COGEO, nodata=1, resampling_method="bilinear") as cog: + with Reader(COGEO, options={"nodata": 1, "resampling_method": "bilinear"}) as cog: data, _ = cog.tile(43, 25, 7) assert not numpy.array_equal(data_default, data) - with COGReader(COG_SCALE, unscale=True) as cog: + with Reader(COG_SCALE, options={"unscale": True}) as cog: p = cog.point(310000, 4100000, coord_crs=cog.dataset.crs) - assert round(p[0], 3) == 1000.892 + assert round(float(p.data[0]), 3) == 1000.892 # passing unscale in method should overwrite the defaults p = cog.point(310000, 4100000, coord_crs=cog.dataset.crs, unscale=False) - assert p[0] == 8917 + assert p.data[0] == 8917 cutline = "POLYGON ((13 1685, 1010 6, 2650 967, 1630 2655, 13 1685))" - with COGReader(COGEO, vrt_options={"cutline": cutline}) as cog: + with Reader(COGEO, options={"vrt_options": {"cutline": cutline}}) as cog: _, mask = cog.preview() assert not mask.all() @@ -434,7 +434,7 @@ def callback(data, mask): data = data * 2 return data, mask - with COGReader(COGEO, nodata=1, post_process=callback) as cog: + with Reader(COGEO, options={"nodata": 1, "post_process": callback}) as cog: data_init, _ = cog.tile(43, 25, 7, post_process=None) data, mask = cog.tile(43, 25, 7) assert mask.all() @@ -442,70 +442,19 @@ def callback(data, mask): lon = -56.624124590533825 lat = 73.52687881825946 - with COGReader(COG_NODATA, post_process=callback) as cog: - pts = cog.point(lon, lat) + with Reader(COG_NODATA, options={"post_process": callback}) as cog: + pt = cog.point(lon, lat) - with COGReader(COG_NODATA) as cog: - pts_init = cog.point(lon, lat) - assert pts[0] == pts_init[0] * 2 + with Reader(COG_NODATA) as cog: + pt_init = cog.point(lon, lat) + assert pt.data[0] == pt_init.data[0] * 2 def test_cog_with_internal_gcps(): """Make sure file gets re-projected using gcps.""" - with pytest.warns(DeprecationWarning): - with GCPCOGReader(COG_GCPS, nodata=0) as cog: - assert cog.bounds - assert cog.nodata == 0 - assert isinstance(cog.src_dataset, DatasetReader) - assert isinstance(cog.dataset, WarpedVRT) - - assert cog.minzoom == 7 - assert cog.maxzoom == 10 - - metadata = cog.info() - assert len(metadata.band_metadata) == 1 - assert metadata.band_descriptions == [("1", "")] - - tile_z = 8 - tile_x = 183 - tile_y = 120 - data, mask = cog.tile(tile_x, tile_y, tile_z) - assert data.shape == (1, 256, 256) - assert mask.shape == (256, 256) - - # https://github.com/rasterio/rasterio/issues/2092 - # assert cog.dataset.closed - assert cog.src_dataset.closed - - with pytest.warns(DeprecationWarning): - with rasterio.open(COG_GCPS) as dst: - with GCPCOGReader(None, src_dataset=dst, nodata=0) as cog: - assert cog.bounds - assert cog.nodata == 0 - assert isinstance(cog.src_dataset, DatasetReader) - assert isinstance(cog.dataset, WarpedVRT) - - assert cog.minzoom == 7 - assert cog.maxzoom == 10 - - metadata = cog.info() - assert len(metadata.band_metadata) == 1 - assert metadata.band_descriptions == [("1", "")] - - tile_z = 8 - tile_x = 183 - tile_y = 120 - data, mask = cog.tile(tile_x, tile_y, tile_z) - assert data.shape == (1, 256, 256) - assert mask.shape == (256, 256) - # https://github.com/rasterio/rasterio/issues/2092 - # assert cog.dataset.closed - assert not cog.src_dataset.closed - assert cog.src_dataset.closed - - with COGReader(COG_GCPS, nodata=0) as cog: + with Reader(COG_GCPS, options={"nodata": 0}) as cog: assert cog.bounds - assert cog.nodata == 0 + assert cog.info().nodata_value == 0 assert isinstance(cog.dataset, WarpedVRT) assert cog.minzoom == 7 @@ -513,7 +462,7 @@ def test_cog_with_internal_gcps(): metadata = cog.info() assert len(metadata.band_metadata) == 1 - assert metadata.band_descriptions == [("1", "")] + assert metadata.band_descriptions == [("b1", "")] tile_z = 8 tile_x = 183 @@ -531,9 +480,9 @@ def test_cog_with_internal_gcps(): src_crs=dst.gcps[1], src_transform=transform.from_gcps(dst.gcps[0]), ) as vrt: - with COGReader(None, dataset=vrt, nodata=0) as cog: + with Reader(None, dataset=vrt, options={"nodata": 0}) as cog: assert cog.bounds - assert cog.nodata == 0 + assert cog.info().nodata_value == 0 assert isinstance(cog.dataset, WarpedVRT) assert cog.minzoom == 7 @@ -541,7 +490,7 @@ def test_cog_with_internal_gcps(): metadata = cog.info() assert len(metadata.band_metadata) == 1 - assert metadata.band_descriptions == [("1", "")] + assert metadata.band_descriptions == [("b1", "")] tile_z = 8 tile_x = 183 @@ -566,7 +515,7 @@ def parse_img(content: bytes) -> Dict[Any, Any]: def test_imageData_output(): """Test ImageData output.""" - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: img = cog.tile(43, 24, 7) assert img.data.shape == (1, 256, 256) assert img.mask.all() @@ -636,9 +585,12 @@ def test_imageData_output(): img = cog.preview(max_size=128) assert img.data.shape == (1, 128, 128) - assert img.bounds == cog.dataset.bounds meta = parse_img(img.render(img_format="GTiff")) assert meta["crs"] == cog.dataset.crs + # Bounds should be the same but VRT might introduce some rounding issue + for x, y in zip(img.bounds, cog.dataset.bounds): + assert round(x, 5) == round(y, 5) + # assert img.bounds == cog.dataset.bounds def test_feature_valid(): @@ -667,10 +619,10 @@ def test_feature_valid(): }, } - with COGReader(COG_NODATA) as cog: + with Reader(COG_NODATA) as cog: img = cog.feature(feature, max_size=1024) assert img.data.shape == (1, 348, 1024) - assert img.band_names == ["1"] + assert img.band_names == ["b1"] img = cog.feature(feature, dst_crs=cog.dataset.crs, max_size=1024) assert img.data.shape == (1, 1024, 869) @@ -701,7 +653,7 @@ def test_feature_valid(): max_size=1024, ) assert img.data.shape == (2, 348, 1024) - assert img.band_names == ["1", "1"] + assert img.band_names == ["b1", "b1"] # feature overlaping on mask area mask_feat = { @@ -748,12 +700,12 @@ def test_feature_valid(): def test_tiling_ignores_padding_if_web_friendly_internal_tiles_exist(): """Ignore Padding when COG is aligned.""" - with COGReader(COG_WEB) as cog: + with Reader(COG_WEB) as cog: img = cog.tile(147, 182, 9, padding=0, resampling_method="bilinear") img2 = cog.tile(147, 182, 9, padding=100, resampling_method="bilinear") assert numpy.array_equal(img.data, img2.data) - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: img = cog.tile(43, 24, 7, padding=0, resampling_method="bilinear") img2 = cog.tile(43, 24, 7, padding=100, resampling_method="bilinear") assert not numpy.array_equal(img.data, img2.data) @@ -762,22 +714,21 @@ def test_tiling_ignores_padding_if_web_friendly_internal_tiles_exist(): def test_tile_read_alpha(): """Read masked area.""" # non-boundless tile covering the alpha masked part - with COGReader(COG_ALPHA) as cog: - with pytest.warns(AlphaBandWarning): - nb = cog.dataset.count - img = cog.tile(876432, 1603670, 22) - assert ( - not nb == img.count - ) # rio-tiler removes the alpha band from the `data` array - assert img.data.shape == (3, 256, 256) - assert not img.mask.all() + with Reader(COG_ALPHA) as cog: + nb = cog.dataset.count + img = cog.tile(876432, 1603670, 22) + assert ( + not nb == img.count + ) # rio-tiler removes the alpha band from the `data` array + assert img.data.shape == (3, 256, 256) + assert not img.mask.all() def test_tile_read_mask(): """Read masked area.""" with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"): # non-boundless tile covering the masked part - with COGReader(COG_MASK) as cog: + with Reader(COG_MASK) as cog: img = cog.tile(876431, 1603669, 22, tilesize=16) assert img.data.shape == (3, 16, 16) assert img.mask.shape == (16, 16) @@ -793,7 +744,7 @@ def test_tile_read_extmask(): """Read masked area.""" # non-boundless tile covering the masked part with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="TRUE"): - with COGReader(COG_EXTMASK) as cog: + with Reader(COG_EXTMASK) as cog: img = cog.tile(876431, 1603669, 22) assert img.data.shape == (3, 256, 256) assert img.mask.shape == (256, 256) @@ -802,7 +753,7 @@ def test_tile_read_extmask(): def test_dateline(): """Read tile from data crossing the antimeridian.""" - with COGReader(COG_DLINE) as cog: + with Reader(COG_DLINE) as cog: img = cog.tile(0, 84, 8, tilesize=64) assert img.data.shape == (1, 64, 64) @@ -812,23 +763,21 @@ def test_dateline(): def test_fullEarth(): """Should read tile for COG spanning the whole earth.""" - with COGReader(COG_EARTH) as cog: + with Reader(COG_EARTH) as cog: img = cog.tile(1, 42, 7, tilesize=64) assert img.data.shape == (1, 64, 64) img = cog.tile(127, 42, 7, tilesize=64) assert img.data.shape == (1, 64, 64) - with COGReader( - COG_EARTH, tms=morecantile.tms.get("EuropeanETRS89_LAEAQuad") - ) as cog: + with Reader(COG_EARTH, tms=morecantile.tms.get("EuropeanETRS89_LAEAQuad")) as cog: img = cog.tile(0, 0, 1, tilesize=64) assert img.data.shape == (1, 64, 64) def test_read(): """Should read the entire dataset.""" - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: img = cog.read() assert numpy.array_equal(img.data, cog.dataset.read(indexes=(1,))) assert img.width == cog.dataset.width @@ -854,21 +803,24 @@ def test_read(): def test_no_overviews(): """Should warns when no overviews are found.""" with pytest.warns(NoOverviewWarning): - with COGReader(GEOTIFF): + with Reader(GEOTIFF): pass def test_nonearthbody(): - """COGReader should work with non-earth dataset.""" + """Reader should work with non-earth dataset.""" + EUROPA_SPHERE = CRS.from_proj4("+proj=longlat +R=1560800 +no_defs") + with pytest.warns(UserWarning): - with COGReader(COG_EUROPA) as cog: + with Reader(COG_EUROPA) as cog: assert cog.minzoom == 0 assert cog.maxzoom == 24 - with pytest.warns(None) as warnings: - with COGReader(COG_EUROPA) as cog: + # Warns because of zoom level in WebMercator can't be defined + with pytest.warns(UserWarning) as w: + with Reader(COG_EUROPA, geographic_crs=EUROPA_SPHERE) as cog: assert cog.info() - assert len(warnings) == 2 + assert len(w) == 2 img = cog.read() assert numpy.array_equal(img.data, cog.dataset.read(indexes=(1,))) @@ -884,29 +836,29 @@ def test_nonearthbody(): lon = (cog.bounds[0] + cog.bounds[2]) / 2 lat = (cog.bounds[1] + cog.bounds[3]) / 2 - assert cog.point(lon, lat, coord_crs=cog.crs)[0] is not None + assert cog.point(lon, lat, coord_crs=cog.crs).data[0] is not None - europa_crs = CRS.from_authority("ESRI", 104915) - tms = TileMatrixSet.custom( - crs=europa_crs, - extent=europa_crs.area_of_use.bounds, - matrix_scale=[2, 1], - ) - with pytest.warns(None) as warnings: - with COGReader(COG_EUROPA, tms=tms) as cog: - assert cog.minzoom == 4 - assert cog.maxzoom == 6 + with pytest.warns(UserWarning): + europa_crs = CRS.from_authority("ESRI", 104915) + tms = TileMatrixSet.custom( + crs=europa_crs, + extent=europa_crs.area_of_use.bounds, + matrix_scale=[2, 1], + ) - # Get Tile covering the UL corner - bounds = transform_bounds(cog.crs, tms.rasterio_crs, *cog.bounds) - t = tms._tile(bounds[0], bounds[1], cog.minzoom) - img = cog.tile(t.x, t.y, t.z) + with Reader(COG_EUROPA, tms=tms, geographic_crs=EUROPA_SPHERE) as cog: + assert cog.info() + assert cog.minzoom == 4 + assert cog.maxzoom == 6 - assert img.height == 256 - assert img.width == 256 - assert img.crs == tms.rasterio_crs + # Get Tile covering the UL corner + bounds = transform_bounds(cog.crs, tms.rasterio_crs, *cog.bounds) + t = tms._tile(bounds[0], bounds[1], cog.minzoom) + img = cog.tile(t.x, t.y, t.z) - assert len(warnings) == 0 + assert img.height == 256 + assert img.width == 256 + assert img.crs == tms.rasterio_crs def test_nonearth_custom(): @@ -930,7 +882,7 @@ def test_nonearth_custom(): ) @attr.s - class MarsReader(COGReader): + class MarsReader(Reader): """Use custom geographic CRS.""" geographic_crs: rasterio.crs.CRS = attr.ib( @@ -938,14 +890,12 @@ class MarsReader(COGReader): default=rasterio.crs.CRS.from_proj4("+proj=longlat +R=3396190 +no_defs"), ) - with pytest.warns(None) as warnings: + with warnings.catch_warnings(): with MarsReader(COG_MARS, tms=mars_tms) as cog: assert cog.geographic_bounds[0] > -180 - assert len(warnings) == 0 - - with pytest.warns(None) as warnings: - with COGReader( + with warnings.catch_warnings(): + with Reader( COG_MARS, tms=mars_tms, geographic_crs=rasterio.crs.CRS.from_proj4( @@ -954,4 +904,31 @@ class MarsReader(COGReader): ) as cog: assert cog.geographic_bounds[0] > -180 - assert len(warnings) == 0 + +def test_tms_tilesize_and_zoom(): + """Test the influence of tms tilesize on COG zoom levels.""" + with Reader(COG_NODATA) as cog: + assert cog.minzoom == 5 + assert cog.maxzoom == 9 + + tms_128 = TileMatrixSet.custom( + WEB_MERCATOR_TMS.xy_bbox, + WEB_MERCATOR_TMS.crs, + title="mercator with 64 tilesize", + tile_width=64, + tile_height=64, + ) + with Reader(COG_NODATA, tms=tms_128) as cog: + assert cog.minzoom == 5 + assert cog.maxzoom == 11 + + tms_2048 = TileMatrixSet.custom( + WEB_MERCATOR_TMS.xy_bbox, + WEB_MERCATOR_TMS.crs, + title="mercator with 2048 tilesize", + tile_width=2048, + tile_height=2048, + ) + with Reader(COG_NODATA, tms=tms_2048) as cog: + assert cog.minzoom == 5 + assert cog.maxzoom == 6 diff --git a/tests/test_io_stac.py b/tests/test_io_stac.py index daf93a52..247dfbb7 100644 --- a/tests/test_io_stac.py +++ b/tests/test_io_stac.py @@ -4,6 +4,7 @@ import os from unittest.mock import patch +import numpy import pytest import rasterio @@ -119,7 +120,7 @@ def raise_for_status(self): assert s3_get.call_args[0] == ("somewhereovertherainbow.io", "mystac.json") -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_tile_valid(rio): """Should raise or return tiles.""" rio.open = mock_rasterio_open @@ -138,22 +139,29 @@ def test_tile_valid(rio): img = stac.tile(71, 102, 8, assets="green") assert img.data.shape == (1, 256, 256) assert img.mask.shape == (256, 256) - assert img.band_names == ["green_1"] + assert img.band_names == ["green_b1"] - data, mask = stac.tile(71, 102, 8, assets=("green",)) - assert data.shape == (1, 256, 256) - assert mask.shape == (256, 256) + img = stac.tile(71, 102, 8, assets=("green",)) + assert img.data.shape == (1, 256, 256) + assert img.mask.shape == (256, 256) + assert img.band_names == ["green_b1"] + + img = stac.tile(71, 102, 8, assets=("green", "red")) + assert img.data.shape == (2, 256, 256) + assert img.mask.shape == (256, 256) + assert img.band_names == ["green_b1", "red_b1"] - img = stac.tile(71, 102, 8, expression="green/red") + img = stac.tile(71, 102, 8, expression="green_b1/red_b1") assert img.data.shape == (1, 256, 256) assert img.mask.shape == (256, 256) - # Note: Here we loose the information about the band - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] with pytest.warns(ExpressionMixingWarning): - img = stac.tile(71, 102, 8, assets=("green", "red"), expression="green/red") + img = stac.tile( + 71, 102, 8, assets=("green", "red"), expression="green_b1/red_b1" + ) assert img.data.shape == (1, 256, 256) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] img = stac.tile( 71, @@ -170,7 +178,7 @@ def test_tile_valid(rio): ) assert img.data.shape == (3, 256, 256) assert img.mask.shape == (256, 256) - assert img.band_names == ["green_1", "green_1", "red_1"] + assert img.band_names == ["green_b1", "green_b1", "red_b1"] # check backward compatibility for `indexes` img = stac.tile( @@ -178,25 +186,29 @@ def test_tile_valid(rio): 102, 8, assets=("green", "red"), - indexes=1, + indexes=(1, 1), ) - assert img.data.shape == (2, 256, 256) + assert img.data.shape == (4, 256, 256) assert img.mask.shape == (256, 256) - assert img.band_names == ["green_1", "red_1"] + assert img.band_names == ["green_b1", "green_b1", "red_b1", "red_b1"] - img = stac.tile( - 71, - 102, - 8, - assets=("green", "red"), - asset_expression={"green": "b1*2;b1", "red": "b1*2"}, - ) + img = stac.tile(71, 102, 8, expression="green_b1*2;green_b1;red_b1*2") assert img.data.shape == (3, 256, 256) assert img.mask.shape == (256, 256) assert img.band_names == ["green_b1*2", "green_b1", "red_b1*2"] + # Should raise KeyError because of missing band 2 + with pytest.raises(KeyError): + img = stac.tile( + 71, + 102, + 8, + expression="green_b1/red_b2", + asset_indexes={"green": 1, "red": 1}, + ) + -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_part_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -214,25 +226,25 @@ def test_part_valid(rio): img = stac.part(bbox, assets="green") assert img.data.shape == (1, 73, 83) assert img.mask.shape == (73, 83) - assert img.band_names == ["green_1"] + assert img.band_names == ["green_b1"] - data, mask = stac.part(bbox, assets=("green",)) - assert data.shape == (1, 73, 83) - assert mask.shape == (73, 83) + img = stac.part(bbox, assets=("green",)) + assert img.data.shape == (1, 73, 83) + assert img.mask.shape == (73, 83) - img = stac.part(bbox, expression="green/red") + img = stac.part(bbox, expression="green_b1/red_b1") assert img.data.shape == (1, 73, 83) assert img.mask.shape == (73, 83) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] - data, mask = stac.part(bbox, assets="green", max_size=30) - assert data.shape == (1, 27, 30) - assert mask.shape == (27, 30) + img = stac.part(bbox, assets="green", max_size=30) + assert img.data.shape == (1, 27, 30) + assert img.mask.shape == (27, 30) with pytest.warns(ExpressionMixingWarning): - img = stac.part(bbox, assets=("green", "red"), expression="green/red") + img = stac.part(bbox, assets=("green", "red"), expression="green_b1/red_b1") assert img.data.shape == (1, 73, 83) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] img = stac.part( bbox, @@ -247,24 +259,20 @@ def test_part_valid(rio): ) assert img.data.shape == (3, 73, 83) assert img.mask.shape == (73, 83) - assert img.band_names == ["green_1", "green_1", "red_1"] + assert img.band_names == ["green_b1", "green_b1", "red_b1"] img = stac.part(bbox, assets=("green", "red"), indexes=1) assert img.data.shape == (2, 73, 83) assert img.mask.shape == (73, 83) - assert img.band_names == ["green_1", "red_1"] + assert img.band_names == ["green_b1", "red_b1"] - img = stac.part( - bbox, - assets=("green", "red"), - asset_expression={"green": "b1*2;b1", "red": "b1*2"}, - ) + img = stac.part(bbox, expression="green_b1*2;green_b1;red_b1*2") assert img.data.shape == (3, 73, 83) assert img.mask.shape == (73, 83) assert img.band_names == ["green_b1*2", "green_b1", "red_b1*2"] -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_preview_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -280,21 +288,21 @@ def test_preview_valid(rio): img = stac.preview(assets="green") assert img.data.shape == (1, 259, 255) assert img.mask.shape == (259, 255) - assert img.band_names == ["green_1"] + assert img.band_names == ["green_b1"] - data, mask = stac.preview(assets=("green",)) - assert data.shape == (1, 259, 255) - assert mask.shape == (259, 255) + img = stac.preview(assets=("green",)) + assert img.data.shape == (1, 259, 255) + assert img.mask.shape == (259, 255) - img = stac.preview(expression="green/red") + img = stac.preview(expression="green_b1/red_b1") assert img.data.shape == (1, 259, 255) assert img.mask.shape == (259, 255) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] with pytest.warns(ExpressionMixingWarning): - img = stac.preview(assets=("green", "red"), expression="green/red") + img = stac.preview(assets=("green", "red"), expression="green_b1/red_b1") assert img.data.shape == (1, 259, 255) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] img = stac.preview( assets=("green", "red"), @@ -308,23 +316,20 @@ def test_preview_valid(rio): ) assert img.data.shape == (3, 259, 255) assert img.mask.shape == (259, 255) - assert img.band_names == ["green_1", "green_1", "red_1"] + assert img.band_names == ["green_b1", "green_b1", "red_b1"] img = stac.preview(assets=("green", "red"), indexes=1) assert img.data.shape == (2, 259, 255) assert img.mask.shape == (259, 255) - assert img.band_names == ["green_1", "red_1"] + assert img.band_names == ["green_b1", "red_b1"] - img = stac.preview( - assets=("green", "red"), - asset_expression={"green": "b1*2;b1", "red": "b1*2"}, - ) + img = stac.preview(expression="green_b1*2;green_b1;red_b1*2") assert img.data.shape == (3, 259, 255) assert img.mask.shape == (259, 255) assert img.band_names == ["green_b1*2", "green_b1", "red_b1*2"] -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_point_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -337,53 +342,52 @@ def test_point_valid(rio): with pytest.raises(MissingAssets): stac.point(-80.477, 33.4453) - data = stac.point(-80.477, 33.4453, assets="green") - assert len(data) == 1 + pt = stac.point(-80.477, 33.4453, assets="green") + assert len(pt.data) == 1 + assert pt.band_names == ["green_b1"] + + pt = stac.point(-80.477, 33.4453, assets=("green",)) + assert len(pt.data) == 1 + assert pt.band_names == ["green_b1"] - data = stac.point(-80.477, 33.4453, assets=("green",)) - assert len(data) == 1 + pt = stac.point(-80.477, 33.4453, assets=("green", "red")) + assert len(pt.data) == 2 + assert numpy.array_equal(pt.data, numpy.array([7994, 7003])) + assert pt.band_names == ["green_b1", "red_b1"] - data = stac.point(-80.477, 33.4453, expression="green/red") - assert len(data) == 1 + pt = stac.point(-80.477, 33.4453, expression="green_b1/red_b1") + assert len(pt.data) == 1 + assert numpy.array_equal(pt.data, numpy.array([7994 / 7003])) + assert pt.band_names == ["green_b1/red_b1"] with pytest.warns(ExpressionMixingWarning): - data = stac.point( - -80.477, 33.4453, assets=("green", "red"), expression="green/red" + pt = stac.point( + -80.477, 33.4453, assets=("green", "red"), expression="green_b1/red_b1" ) - assert len(data) == 1 + assert len(pt.data) == 1 + assert pt.band_names == ["green_b1/red_b1"] - data = stac.point( + pt = stac.point( -80.477, 33.4453, assets=("green", "red"), asset_indexes={"green": (1, 1), "red": 1}, ) - assert len(data) == 2 - assert len(data[0]) == 2 - assert len(data[1]) == 1 + assert len(pt.data) == 3 + assert numpy.array_equal(pt.data, numpy.array([7994, 7994, 7003])) + assert pt.band_names == ["green_b1", "green_b1", "red_b1"] - data = stac.point( - -80.477, - 33.4453, - assets=("green", "red"), - indexes=1, - ) - assert len(data) == 2 - assert len(data[0]) == 1 - assert len(data[1]) == 1 + pt = stac.point(-80.477, 33.4453, assets=("green", "red"), indexes=1) + assert len(pt.data) == 2 + assert numpy.array_equal(pt.data, numpy.array([7994, 7003])) + assert pt.band_names == ["green_b1", "red_b1"] - data = stac.point( - -80.477, - 33.4453, - assets=("green", "red"), - asset_expression={"green": "b1*2;b1", "red": "b1*2"}, - ) - assert len(data) == 2 - assert len(data[0]) == 2 - assert len(data[1]) == 1 + pt = stac.point(-80.477, 33.4453, expression="green_b1*2;green_b1;red_b1*2") + assert len(pt.data) == 3 + assert pt.band_names == ["green_b1*2", "green_b1", "red_b1*2"] -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_statistics_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -400,11 +404,11 @@ def test_statistics_valid(rio): stats = stac.statistics(assets="green") assert stats["green"] - assert isinstance(stats["green"]["1"], BandStatistics) + assert isinstance(stats["green"]["b1"], BandStatistics) stats = stac.statistics(assets=("green", "red"), hist_options={"bins": 20}) assert len(stats) == 2 - assert len(stats["green"]["1"]["histogram"][0]) == 20 + assert len(stats["green"]["b1"]["histogram"][0]) == 20 # Check that asset_expression is passed stats = stac.statistics( @@ -419,17 +423,17 @@ def test_statistics_valid(rio): assets=("green", "red"), asset_indexes={"green": 1, "red": 1} ) assert stats["green"] - assert isinstance(stats["green"]["1"], BandStatistics) - assert isinstance(stats["red"]["1"], BandStatistics) + assert isinstance(stats["green"]["b1"], BandStatistics) + assert isinstance(stats["red"]["b1"], BandStatistics) # Check that asset_indexes is passed stats = stac.statistics(assets=("green", "red"), indexes=1) assert stats["green"] - assert isinstance(stats["green"]["1"], BandStatistics) - assert isinstance(stats["red"]["1"], BandStatistics) + assert isinstance(stats["green"]["b1"], BandStatistics) + assert isinstance(stats["red"]["b1"], BandStatistics) -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_merged_statistics_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -438,28 +442,25 @@ def test_merged_statistics_valid(rio): with pytest.warns(UserWarning): stats = stac.merged_statistics() assert len(stats) == 3 - assert isinstance(stats["red_1"], BandStatistics) - assert stats["red_1"] - assert stats["green_1"] - assert stats["blue_1"] + assert isinstance(stats["red_b1"], BandStatistics) + assert stats["red_b1"] + assert stats["green_b1"] + assert stats["blue_b1"] with pytest.raises(InvalidAssetName): stac.merged_statistics(assets="vert") stats = stac.merged_statistics(assets="green") - assert isinstance(stats["green_1"], BandStatistics) + assert isinstance(stats["green_b1"], BandStatistics) stats = stac.merged_statistics( assets=("green", "red"), hist_options={"bins": 20} ) assert len(stats) == 2 - assert len(stats["green_1"]["histogram"][0]) == 20 - assert len(stats["red_1"]["histogram"][0]) == 20 + assert len(stats["green_b1"]["histogram"][0]) == 20 + assert len(stats["red_b1"]["histogram"][0]) == 20 - # Check that asset_expression is passed - stats = stac.merged_statistics( - assets=("green", "red"), asset_expression={"green": "b1*2", "red": "b1+100"} - ) + stats = stac.merged_statistics(expression="green_b1*2;green_b1;red_b1+100") assert isinstance(stats["green_b1*2"], BandStatistics) assert isinstance(stats["red_b1+100"], BandStatistics) @@ -467,22 +468,11 @@ def test_merged_statistics_valid(rio): stats = stac.merged_statistics( assets=("green", "red"), asset_indexes={"green": 1, "red": 1} ) - assert isinstance(stats["green_1"], BandStatistics) - assert isinstance(stats["red_1"], BandStatistics) - - # Check Expression - stats = stac.merged_statistics(expression="green/red") - assert isinstance(stats["green/red"], BandStatistics) + assert isinstance(stats["green_b1"], BandStatistics) + assert isinstance(stats["red_b1"], BandStatistics) - # Check that we can use expression and asset_expression - stats = stac.merged_statistics( - expression="green/red", - asset_expression={"green": "b1*2", "red": "b1+100"}, - ) - assert isinstance(stats["green/red"], BandStatistics) - -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_info_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -506,16 +496,30 @@ def test_info_valid(rio): def test_parse_expression(): - """.""" + """Parse assets expressions.""" with STACReader(STAC_PATH) as stac: - assert sorted(stac.parse_expression("green*red+red/blue+2.0")) == [ + assert sorted( + stac.parse_expression("green_b1*red_b1+red_b1/blue_b1+2.0;red_b1") + ) == [ "blue", "green", "red", ] + # make sure we match full word only + with STACReader(STAC_PATH) as stac: + assert sorted( + stac.parse_expression("greenish_b1*red_b1+red_b1/blue_b1+2.0;red_b1") + ) == ["blue", "red"] -@patch("rio_tiler.io.cogeo.rasterio") + # make sure we match full word only + with STACReader(STAC_PATH) as stac: + assert sorted( + stac.parse_expression("green_b10foo*red_b1+red_b1/blue_b1+2.0;red_b1") + ) == ["blue", "red"] + + +@patch("rio_tiler.io.rasterio.rasterio") def test_feature_valid(rio): """Should raise or return data.""" rio.open = mock_rasterio_open @@ -553,43 +557,41 @@ def test_feature_valid(rio): img = stac.feature(feat, assets="green") assert img.data.shape == (1, 118, 96) assert img.mask.shape == (118, 96) - assert img.band_names == ["green_1"] + assert img.band_names == ["green_b1"] - data, mask = stac.feature(feat, assets=("green",)) - assert data.shape == (1, 118, 96) - assert mask.shape == (118, 96) + img = stac.feature(feat, assets=("green",)) + assert img.data.shape == (1, 118, 96) + assert img.mask.shape == (118, 96) - img = stac.feature(feat, expression="green/red") + img = stac.feature(feat, expression="green_b1/red_b1") assert img.data.shape == (1, 118, 96) assert img.mask.shape == (118, 96) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] - data, mask = stac.feature(feat, assets="green", max_size=30) - assert data.shape == (1, 30, 25) - assert mask.shape == (30, 25) + img = stac.feature(feat, assets="green", max_size=30) + assert img.data.shape == (1, 30, 25) + assert img.mask.shape == (30, 25) with pytest.warns(ExpressionMixingWarning): - img = stac.feature(feat, assets=("green", "red"), expression="green/red") + img = stac.feature( + feat, assets=("green", "red"), expression="green_b1/red_b1" + ) assert img.data.shape == (1, 118, 96) - assert img.band_names == ["green/red"] + assert img.band_names == ["green_b1/red_b1"] img = stac.feature( feat, assets=("green", "red"), asset_indexes={"green": (1, 1), "red": 1} ) assert img.data.shape == (3, 118, 96) assert img.mask.shape == (118, 96) - assert img.band_names == ["green_1", "green_1", "red_1"] + assert img.band_names == ["green_b1", "green_b1", "red_b1"] img = stac.feature(feat, assets=("green", "red"), indexes=1) assert img.data.shape == (2, 118, 96) assert img.mask.shape == (118, 96) - assert img.band_names == ["green_1", "red_1"] + assert img.band_names == ["green_b1", "red_b1"] - img = stac.feature( - feat, - assets=("green", "red"), - asset_expression={"green": "b1*2;b1", "red": "b1*2"}, - ) + img = stac.feature(feat, expression="green_b1*2;green_b1;red_b1*2") assert img.data.shape == (3, 118, 96) assert img.mask.shape == (118, 96) assert img.band_names == ["green_b1*2", "green_b1", "red_b1*2"] @@ -662,3 +664,16 @@ def raise_for_status(self): s3_get.assert_called_once() assert s3_get.call_args[1]["request_pays"] assert s3_get.call_args[0] == ("somewhereovertherainbow.io", "mystac.json") + + +@patch("rio_tiler.io.rasterio.rasterio") +def test_img_dataset_stats(rio): + """Make sure dataset statistics are forwarded.""" + rio.open = mock_rasterio_open + + with STACReader(STAC_PATH) as stac: + img = stac.preview(assets=("green", "red")) + assert img.dataset_statistics == [(6883, 62785), (6101, 65035)] + + img = stac.preview(expression="green_b1/red_b1") + assert img.dataset_statistics == [(6883 / 65035, 62785 / 6101)] diff --git a/tests/test_io_xarray.py b/tests/test_io_xarray.py new file mode 100644 index 00000000..c4f1cf1b --- /dev/null +++ b/tests/test_io_xarray.py @@ -0,0 +1,87 @@ +"""tests rio_tiler.io.xarray.XarrayReader""" + +import os +from datetime import datetime + +import numpy +import xarray + +from rio_tiler.io import XarrayReader + +PREFIX = os.path.join(os.path.dirname(__file__), "fixtures") + +planet = os.path.join(PREFIX, "PLANET_SCOPE_3D.nc") + + +def test_xarray_reader(): + """test XarrayReader.""" + arr = numpy.random.randn(1, 33, 35) + data = xarray.DataArray( + arr, + dims=("time", "y", "x"), + coords={ + "x": list(range(-170, 180, 10)), + "y": list(range(-80, 85, 5)), + "time": [datetime(2022, 1, 1)], + }, + ) + data.attrs.update({"valid_min": arr.min(), "valid_max": arr.max()}) + + data.rio.write_crs("epsg:4326", inplace=True) + with XarrayReader(data) as dst: + info = dst.info() + assert info.minzoom == 0 + assert info.maxzoom == 0 + assert info.band_metadata == [("b1", {})] + assert info.band_descriptions == [("b1", "2022-01-01T00:00:00.000000000")] + assert info.height == 33 + assert info.width == 35 + assert info.count == 1 + assert info.attrs + + with XarrayReader(data) as dst: + img = dst.tile(0, 0, 0) + assert img.count == 1 + assert img.width == 256 + assert img.height == 256 + assert img.band_names == ["2022-01-01T00:00:00.000000000"] + assert img.dataset_statistics == ((arr.min(), arr.max()),) + + img = dst.part((-160, -80, 160, 80)) + assert img.count == 1 + assert img.band_names == ["2022-01-01T00:00:00.000000000"] + + pt = dst.point(0, 0) + assert pt.count == 1 + assert pt.band_names == ["2022-01-01T00:00:00.000000000"] + assert pt.coordinates + + feat = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-92.46093749999999, 72.91963546581484], + [-148.0078125, 33.137551192346145], + [-143.08593749999997, -28.613459424004414], + [43.9453125, -47.04018214480665], + [142.734375, -12.897489183755892], + [157.5, 68.13885164925573], + [58.71093750000001, 74.95939165894974], + [-40.42968749999999, 75.14077784070429], + [-92.46093749999999, 72.91963546581484], + ] + ], + }, + } + img = dst.feature(feat) + assert img.count == 1 + assert img.band_names == ["2022-01-01T00:00:00.000000000"] + + img = dst.feature(feat, dst_crs="epsg:3857") + assert img.count == 1 + assert img.band_names == ["2022-01-01T00:00:00.000000000"] + assert img.crs.to_epsg() == 3857 + print(img) diff --git a/tests/test_mask.py b/tests/test_mask.py index b86a8da6..8a6e4d96 100644 --- a/tests/test_mask.py +++ b/tests/test_mask.py @@ -8,7 +8,7 @@ from rasterio.coords import BoundingBox from rasterio.crs import CRS -from rio_tiler.io import COGReader +from rio_tiler.io import Reader tiles = { "masked": morecantile.Tile(x=535, y=498, z=10), @@ -43,20 +43,23 @@ def test_mask_bilinear(cloudoptimized_geotiff): src_path = cloudoptimized_geotiff( cog_path, **equator, dtype="uint8", nodata_type="alpha" ) - with COGReader(src_path) as cog: + with Reader(src_path) as cog: data, mask = cog.preview( resampling_method="bilinear", force_binary_mask=True, + max_size=100, ) masknodata = (data[0] != 0).astype(numpy.uint8) * 255 numpy.testing.assert_array_equal(mask, masknodata) - data, mask = cog.preview( + dataf, maskf = cog.preview( resampling_method="bilinear", force_binary_mask=False, + max_size=100, ) - masknodata = (data[0] != 0).astype(numpy.uint8) * 255 - assert not numpy.array_equal(mask, masknodata) + masknodata = (dataf[0] != 0).astype(numpy.uint8) * 255 + assert not numpy.array_equal(maskf, masknodata) + assert not numpy.array_equal(maskf, mask) @pytest.mark.parametrize("resampling", ["bilinear", "nearest"]) @@ -67,7 +70,7 @@ def test_mask(dataset_info, tile_name, resampling, cloudoptimized_geotiff): src_path = cloudoptimized_geotiff(cog_path, **dataset_info) tile = tiles[tile_name] - with COGReader(src_path) as cog: + with Reader(src_path) as cog: data, mask = cog.tile( tile.x, tile.y, diff --git a/tests/test_models.py b/tests/test_models.py index eea50727..2dd14c74 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,6 +5,7 @@ import numpy import pytest import rasterio +from rasterio.io import MemoryFile from rio_tiler.errors import InvalidDatatypeWarning from rio_tiler.models import ImageData @@ -121,3 +122,57 @@ def test_merge_with_diffsize(): img2 = ImageData(numpy.zeros((1, 256, 256))) img = ImageData.create_from_list([img1, img2]) assert len(w) == 0 + + +def test_apply_expression(): + """Apply expression""" + img = ImageData(numpy.zeros((2, 256, 256))) + img2 = img.apply_expression("b1+b2") + assert img.count == 2 + assert img.width == 256 + assert img.height == 256 + assert img.band_names == ["b1", "b2"] + assert img2.count == 1 + assert img2.width == 256 + assert img2.height == 256 + assert img2.band_names == ["b1+b2"] + + +def test_dataset_statistics(): + """Make statistics are preserved on expression""" + data = numpy.zeros((2, 256, 256), dtype="uint8") + data[0, 0:10, 0:10] = 0 + data[0, 10:11, 10:11] = 100 + data[1, 0:10, 0:10] = 100 + data[1, 10:11, 10:11] = 200 + img = ImageData(data, dataset_statistics=[(0, 100), (0, 200)]) + + img2 = img.apply_expression("b1+b2") + assert img2.dataset_statistics == [(0, 300)] + + img2 = img.apply_expression("b1+b2;b1*b2;b1/b1") + assert img2.dataset_statistics == [(0, 300), (0, 20000), (0, 1)] + assert img2.data[0].min() == 0 + assert img2.data[0].max() == 300 + assert img2.data[1].min() == 0 + assert img2.data[1].max() == 20000 + assert img2.data[2].min() == 0 + assert img2.data[2].max() == 1 + + data = numpy.zeros((1, 256, 256), dtype="int16") + data[0, 0:10, 0:10] = 0 + data[0, 10:11, 10:11] = 1 + + img = ImageData(data, dataset_statistics=[(0, 1)]).render(img_format="PNG") + with MemoryFile(img) as mem: + with mem.open() as dst: + arr = dst.read(indexes=1) + assert arr.min() == 0 + assert arr.max() == 255 + + img = ImageData(data).render(img_format="PNG") + with MemoryFile(img) as mem: + with mem.open() as dst: + arr = dst.read(indexes=1) + assert not arr.min() == 0 + assert not arr.max() == 255 diff --git a/tests/test_mosaic.py b/tests/test_mosaic.py index d2d76f39..8d5a3178 100644 --- a/tests/test_mosaic.py +++ b/tests/test_mosaic.py @@ -11,7 +11,7 @@ from rio_tiler import mosaic from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS from rio_tiler.errors import EmptyMosaicError, InvalidMosaicMethod, TileOutsideBounds -from rio_tiler.io import COGReader, STACReader +from rio_tiler.io import Reader, STACReader from rio_tiler.models import ImageData from rio_tiler.mosaic.methods import defaults from rio_tiler.types import DataMaskType @@ -41,19 +41,19 @@ def _read_tile(src_path: str, *args, **kwargs) -> ImageData: """Read tile from an asset""" - with COGReader(src_path) as cog: + with Reader(src_path) as cog: return cog.tile(*args, **kwargs) def _read_part(src_path: str, *args, **kwargs) -> ImageData: """Read part from an asset""" - with COGReader(src_path) as cog: + with Reader(src_path) as cog: return cog.part(*args, **kwargs) def _read_preview(src_path: str, *args, **kwargs) -> DataMaskType: """Read preview from an asset""" - with COGReader(src_path) as cog: + with Reader(src_path) as cog: data, mask = cog.preview(*args, **kwargs) return data, mask @@ -69,10 +69,10 @@ def test_mosaic_tiler(): assert t.dtype == m.dtype img, _ = mosaic.mosaic_reader(assets, _read_tile, x, y, z) - assert img.band_names == ["1", "2", "3"] + assert img.band_names == ["b1", "b2", "b3"] img, _ = mosaic.mosaic_reader(assets, _read_tile, x, y, z, indexes=[1]) - assert img.band_names == ["1"] + assert img.band_names == ["b1"] img, _ = mosaic.mosaic_reader(assets, _read_tile, x, y, z, expression="b1*3") assert img.band_names == ["b1*3"] @@ -249,7 +249,7 @@ def mock_rasterio_open(asset): return rasterio.open(asset) -@patch("rio_tiler.io.cogeo.rasterio") +@patch("rio_tiler.io.rasterio.rasterio") def test_stac_mosaic_tiler(rio): """Test mosaic tiler with STACReader.""" rio.open = mock_rasterio_open @@ -281,7 +281,7 @@ def _reader(src_path: str, *args, **kwargs) -> ImageData: assets="green", threads=0, ) - assert img.band_names == ["green_1"] + assert img.band_names == ["green_b1"] img, _ = mosaic.mosaic_reader( [stac_asset], @@ -289,23 +289,11 @@ def _reader(src_path: str, *args, **kwargs) -> ImageData: 71, 102, 8, - assets=["green"], - asset_expression={"green": "b1*2"}, + expression="green_b1*2", threads=0, ) assert img.band_names == ["green_b1*2"] - img, _ = mosaic.mosaic_reader( - [stac_asset], - _reader, - 71, - 102, - 8, - expression="green*2", - threads=0, - ) - assert img.band_names == ["green*2"] - def test_mosaic_tiler_Stdev(): """Test Stdev mosaic methods.""" @@ -440,10 +428,10 @@ def test_mosaic_tiler_with_imageDataClass(): assert not img.bounds bbox = [-75.98703377413767, 44.93504283293786, -71.337604723999, 47.09685599202324] - with COGReader(assets[0]) as cog: + with Reader(assets[0]) as cog: crs1 = cog.dataset.crs - with COGReader(assets[0]) as cog: + with Reader(assets[0]) as cog: crs2 = cog.dataset.crs img, assets_used = mosaic.mosaic_reader( @@ -456,4 +444,5 @@ def test_mosaic_tiler_with_imageDataClass(): assert img.crs == crs1 == crs2 assert not img.bounds == bbox bbox_in_crs = transform_bounds(WGS84_CRS, crs1, *bbox, densify_pts=21) - assert img.bounds == bbox_in_crs + for xc, yc in zip(img.bounds, bbox_in_crs): + assert round(xc, 5) == round(yc, 5) diff --git a/tests/test_reader.py b/tests/test_reader.py index 4c1884f5..4e6bbe89 100644 --- a/tests/test_reader.py +++ b/tests/test_reader.py @@ -5,6 +5,7 @@ import numpy import pytest import rasterio +from rasterio.warp import transform_bounds from rio_tiler import constants, reader from rio_tiler.errors import PointOutsideBounds, TileOutsideBounds @@ -368,22 +369,253 @@ def test_tile_read_vrt_option(): def test_point(): """Read point values""" + with rasterio.open(COG) as src_dst: + pt = reader.point( + src_dst, + [-53.54620193828792, 73.28439084323475], + coord_crs="epsg:4326", + indexes=1, + nodata=1, + ) + assert pt.data == numpy.array([1]) + assert pt.mask == numpy.array([0]) + assert pt.band_names == ["b1"] + with rasterio.open(COG_SCALE) as src_dst: - p = reader.point(src_dst, [310000, 4100000], coord_crs=src_dst.crs, indexes=1) - assert p == [8917] + pt = reader.point(src_dst, [310000, 4100000], coord_crs=src_dst.crs, indexes=1) + assert pt.data == numpy.array([8917]) + assert pt.mask == numpy.array([255]) + assert pt.band_names == ["b1"] - p = reader.point(src_dst, [310000, 4100000], coord_crs=src_dst.crs) - assert p == [8917] + pt = reader.point(src_dst, [310000, 4100000], coord_crs=src_dst.crs) + assert pt.data == numpy.array([8917]) + assert pt.band_names == ["b1"] with pytest.raises(PointOutsideBounds): reader.point(src_dst, [810000, 4100000], coord_crs=src_dst.crs) - with rasterio.open(S3_MASK_PATH) as src_dst: - # Test with COG + internal mask - assert not reader.point(src_dst, [-104.7753105, 38.953548])[0] - assert reader.point(src_dst, [-104.7753105415, 38.953548], masked=False)[0] == 0 - with rasterio.open(S3_ALPHA_PATH) as src_dst: # Test with COG + Alpha Band - assert not reader.point(src_dst, [-104.77519499, 38.95367054])[0] - assert reader.point(src_dst, [-104.77519499, 38.95367054], masked=False)[0] == 0 + assert reader.point(src_dst, [-104.77519499, 38.95367054]).data[0] + assert ( + reader.point(src_dst, [-104.77519499, 38.95367054]).mask[0] == 0 + ) # Masked + + +def test_part_with_buffer(): + """Make sure buffer works as expected.""" + bounds = [ + -6574807.42497772, + 12210356.646387195, + -6261721.357121638, + 12523442.714243278, + ] + # Read part at full resolution + with rasterio.open(COG) as src_dst: + img_no_buffer = reader.part(src_dst, bounds, dst_crs=constants.WEB_MERCATOR_CRS) + + x_size = img_no_buffer.width + y_size = img_no_buffer.height + + x_res = (bounds[2] - bounds[0]) / x_size + y_res = (bounds[3] - bounds[1]) / y_size + + nx = x_size + 4 + ny = y_size + 4 + + # apply a 2 pixel buffer + bounds_with_buffer = ( + bounds[0] - x_res * 2, + bounds[1] - y_res * 2, + bounds[2] + x_res * 2, + bounds[3] + y_res * 2, + ) + with rasterio.open(COG) as src_dst: + img = reader.part( + src_dst, + bounds_with_buffer, + height=ny, + width=nx, + dst_crs=constants.WEB_MERCATOR_CRS, + ) + assert img.width == nx + assert img.height == ny + + with rasterio.open(COG) as src_dst: + imgb = reader.part( + src_dst, bounds, buffer=2, dst_crs=constants.WEB_MERCATOR_CRS + ) + assert imgb.width == nx + assert imgb.height == ny + + assert numpy.array_equal(img.data, imgb.data) + assert img.bounds == imgb.bounds + + # No resampling is involved. Because we read the full resolution data + # all arrays should be equal + numpy.array_equal(img_no_buffer.data, imgb.data[:, 2:-2, 2:-2]) + numpy.array_equal(img_no_buffer.data, img.data[:, 2:-2, 2:-2]) + + +def test_read(): + """Test reader.read function.""" + with rasterio.open(COG) as src: + img = reader.read(src) + assert img.width == src.width + assert img.height == src.height + assert img.count == src.count + assert img.bounds == src.bounds + assert img.crs == src.crs + + with rasterio.open(COG) as src: + with pytest.warns(UserWarning): + img = reader.read(src, max_size=1000, width=100, height=100) + assert img.width == 100 + assert img.height == 100 + assert img.count == src.count + assert img.bounds == src.bounds + assert img.crs == src.crs + + with rasterio.open(COG) as src: + img = reader.read(src, width=100, height=100) + assert img.width == 100 + assert img.height == 100 + assert img.count == src.count + assert img.bounds == src.bounds + assert img.crs == src.crs + + with rasterio.open(COG) as src: + img = reader.read(src, max_size=100) + assert max(img.width, img.height) == 100 + assert img.count == src.count + assert img.bounds == src.bounds + assert img.crs == src.crs + + with rasterio.open(COG) as src: + img = reader.read(src, dst_crs="epsg:3857") + assert not img.width == src.width + assert not img.height == src.height + assert img.count == src.count + assert not img.bounds == src.bounds + assert not img.crs == src.crs + + with rasterio.open(COG) as src: + img = reader.read(src) + assert img.mask.all() + + with rasterio.open(COG) as src: + img = reader.read(src, nodata=1) + assert not img.mask.all() + + with rasterio.open(COG) as src: + img = reader.read(src, window=((0, 100), (0, 100))) + assert img.width == 100 + assert img.height == 100 + assert img.count == src.count + assert not img.bounds == src.bounds + assert img.crs == src.crs + + # Boundless Read + with rasterio.open(COG) as src: + img = reader.read(src, window=((-10, 100), (-10, 100))) + assert img.width == 110 + assert img.height == 110 + assert img.count == src.count + assert not img.bounds == src.bounds + assert img.crs == src.crs + + with rasterio.open(COG) as src: + img = reader.read(src, window=((0, 4000), (0, 4000))) + assert img.width == 4000 + assert img.height == 4000 + assert img.count == src.count + assert not img.bounds == src.bounds + assert img.crs == src.crs + + # Can't use boundless window with WarpedVRT + with rasterio.open(COG) as src: + with pytest.raises(ValueError): + reader.read(src, window=((0, 4000), (0, 4000)), dst_crs="epsg:3857") + + # Unscale Dataset + with rasterio.open(COG_SCALE) as src: + assert not src.dtypes[0] == numpy.float32 + img = reader.read(src, unscale=True) + assert img.data.dtype == numpy.float32 + + # Dataset with Alpha using WarpedVRT + with rasterio.open(S3_ALPHA_PATH) as src: + img = reader.read(src, dst_crs="epsg:3857") + assert not img.mask.all() + + +def test_part_no_VRT(): + """Test reader.part function without VRT.""" + bounds = [ + -56.6015625, + 73.0001215118412, + -51.67968749999999, + 74.23886253330774, + ] # boundless part + # Read part at full resolution + with rasterio.open(COG) as src_dst: + + bounds_dst_crs = transform_bounds( + "epsg:4326", src_dst.crs, *bounds, densify_pts=21 + ) + + img = reader.part(src_dst, bounds, bounds_crs="epsg:4326") + assert img.height == 1453 + assert img.width == 1613 + assert img.mask[0, 0] == 255 + assert img.mask[-1, -1] == 0 # boundless + assert img.bounds == bounds_dst_crs + + # Use bbox in Image CRS + img_crs = reader.part(src_dst, bounds_dst_crs) + assert img.height == 1453 + assert img.width == 1613 + assert img_crs.mask[0, 0] == 255 + assert img_crs.mask[-1, -1] == 0 # boundless + assert img.bounds == bounds_dst_crs + + # MaxSize + img = reader.part(src_dst, bounds, bounds_crs="epsg:4326", max_size=1024) + assert img.height < 1024 + assert img.width == 1024 + assert img.mask[0, 0] == 255 + assert img.mask[-1, -1] == 0 # boundless + assert img.bounds == bounds_dst_crs + + # Width/Height + img = reader.part( + src_dst, + bounds, + bounds_crs="epsg:4326", + width=100, + height=100, + ) + assert img.height == 100 + assert img.width == 100 + assert img.mask[0, 0] == 255 + assert img.mask[-1, -1] == 0 # boundless + assert img.bounds == bounds_dst_crs + + # Buffer + img = reader.part(src_dst, bounds, bounds_crs="epsg:4326", buffer=1) + assert img.height == 1455 + assert img.width == 1615 + assert img.mask[0, 0] == 255 + assert img.mask[-1, -1] == 0 # boundless + assert not img.bounds == bounds_dst_crs + + # Padding + img = reader.part(src_dst, bounds, bounds_crs="epsg:4326") + img_pad = reader.part(src_dst, bounds, bounds_crs="epsg:4326", padding=1) + assert img_pad.height == 1453 + assert img_pad.width == 1613 + assert img_pad.mask[0, 0] == 255 + assert img_pad.mask[-1, -1] == 0 # boundless + assert img_pad.bounds == bounds_dst_crs + # Padding should not have any influence when not doing any rescaling/reprojection + numpy.array_equal(img_pad.data, img.data) diff --git a/tests/test_utils.py b/tests/test_utils.py index 7730023f..d125a900 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,7 +13,7 @@ from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS from rio_tiler.errors import RioTilerError from rio_tiler.expression import parse_expression -from rio_tiler.io import COGReader +from rio_tiler.io import Reader from .conftest import requires_webp @@ -163,25 +163,17 @@ def test_aligned_with_internaltile(): """Check if COG is in WebMercator and aligned with internal tiles.""" bounds = WEB_MERCATOR_TMS.bounds(43, 25, 7) with rasterio.open(COG_DST) as src_dst: - assert not utils._requested_tile_aligned_with_internal_tile( - src_dst, bounds, 256, 256 - ) + assert not utils._requested_tile_aligned_with_internal_tile(src_dst, bounds) with rasterio.open(NOCOG) as src_dst: - assert not utils._requested_tile_aligned_with_internal_tile( - src_dst, bounds, 256, 256 - ) + assert not utils._requested_tile_aligned_with_internal_tile(src_dst, bounds) bounds = WEB_MERCATOR_TMS.bounds(147, 182, 9) with rasterio.open(COG_NOWEB) as src_dst: - assert not utils._requested_tile_aligned_with_internal_tile( - src_dst, bounds, 256, 256 - ) + assert not utils._requested_tile_aligned_with_internal_tile(src_dst, bounds) with rasterio.open(COG_WEB_TILED) as src_dst: - assert utils._requested_tile_aligned_with_internal_tile( - src_dst, bounds, 256, 256 - ) + assert utils._requested_tile_aligned_with_internal_tile(src_dst, bounds) def test_find_non_alpha(): @@ -289,7 +281,7 @@ def test_cutline(): feature_bounds = featureBounds(feat) - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: cutline = utils.create_cutline(cog.dataset, feat, geometry_crs="epsg:4326") data, mask = cog.part(feature_bounds, vrt_options={"cutline": cutline}) assert not mask.all() @@ -314,7 +306,7 @@ def test_cutline(): }, } - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: with pytest.raises(RioTilerError): utils.create_cutline(cog.dataset, feat_line, geometry_crs="epsg:4326") @@ -342,7 +334,7 @@ def test_cutline(): ], } - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: c = utils.create_cutline(cog.dataset, feat_mp, geometry_crs="epsg:4326") assert "MULTIPOLYGON" in c @@ -361,7 +353,7 @@ def test_cutline(): ], } - with COGReader(COGEO) as cog: + with Reader(COGEO) as cog: with pytest.raises(RioTilerError): utils.create_cutline(cog.dataset, bad_poly, geometry_crs="epsg:4326")