yirgacheffe 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yirgacheffe/__init__.py +17 -0
- yirgacheffe/backends/__init__.py +13 -0
- yirgacheffe/backends/enumeration.py +33 -0
- yirgacheffe/backends/mlx.py +156 -0
- yirgacheffe/backends/numpy.py +110 -0
- yirgacheffe/constants.py +1 -0
- yirgacheffe/h3layer.py +2 -0
- yirgacheffe/layers/__init__.py +44 -0
- yirgacheffe/layers/area.py +91 -0
- yirgacheffe/layers/base.py +265 -0
- yirgacheffe/layers/constant.py +41 -0
- yirgacheffe/layers/group.py +357 -0
- yirgacheffe/layers/h3layer.py +203 -0
- yirgacheffe/layers/rasters.py +333 -0
- yirgacheffe/layers/rescaled.py +94 -0
- yirgacheffe/layers/vectors.py +380 -0
- yirgacheffe/operators.py +738 -0
- yirgacheffe/rounding.py +57 -0
- yirgacheffe/window.py +141 -0
- yirgacheffe-1.2.0.dist-info/METADATA +473 -0
- yirgacheffe-1.2.0.dist-info/RECORD +25 -0
- yirgacheffe-1.2.0.dist-info/WHEEL +5 -0
- yirgacheffe-1.2.0.dist-info/entry_points.txt +2 -0
- yirgacheffe-1.2.0.dist-info/licenses/LICENSE +7 -0
- yirgacheffe-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from math import ceil, floor
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import h3
|
|
5
|
+
import numpy as np
|
|
6
|
+
from osgeo import gdal
|
|
7
|
+
|
|
8
|
+
from ..rounding import round_up_pixels
|
|
9
|
+
from ..window import Area, PixelScale, Window
|
|
10
|
+
from .base import YirgacheffeLayer
|
|
11
|
+
from ..backends import backend
|
|
12
|
+
|
|
13
|
+
class H3CellLayer(YirgacheffeLayer):
|
|
14
|
+
|
|
15
|
+
def __init__(self, cell_id: str, scale: PixelScale, projection: str):
|
|
16
|
+
if not h3.is_valid_cell(cell_id):
|
|
17
|
+
raise ValueError(f"{cell_id} is not a valid H3 cell identifier")
|
|
18
|
+
self.cell_id = cell_id
|
|
19
|
+
self.zoom = h3.get_resolution(cell_id)
|
|
20
|
+
|
|
21
|
+
self.cell_boundary = h3.cell_to_boundary(cell_id)
|
|
22
|
+
|
|
23
|
+
abs_xstep, abs_ystep = abs(scale.xstep), abs(scale.ystep)
|
|
24
|
+
area = Area(
|
|
25
|
+
left=(floor(min(x[1] for x in self.cell_boundary) / abs_xstep) * abs_xstep),
|
|
26
|
+
top=(ceil(max(x[0] for x in self.cell_boundary) / abs_ystep) * abs_ystep),
|
|
27
|
+
right=(ceil(max(x[1] for x in self.cell_boundary) / abs_xstep) * abs_xstep),
|
|
28
|
+
bottom=(floor(min(x[0] for x in self.cell_boundary) / abs_ystep) * abs_ystep),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Statistically, most hex tiles will be within a projection, but some will wrap around
|
|
32
|
+
# the edge, so check if we're hit one of those cases.
|
|
33
|
+
self.centre = h3.cell_to_latlng(cell_id)
|
|
34
|
+
|
|
35
|
+
# Due to time constraints, and that most geospatial data we are working with stops before the poles
|
|
36
|
+
# I'm going to explicitly not handle the poles right now, where you get different distortions from
|
|
37
|
+
# at the 180 line:
|
|
38
|
+
if not area.bottom < self.centre[0] < area.top:
|
|
39
|
+
raise NotImplementedError("Distortion at poles not currently handled")
|
|
40
|
+
|
|
41
|
+
# For wrap around due to hitting the longitunal wrap, we currently just do the naive thing and
|
|
42
|
+
# project a band for the full width of the projection. I have a plan to fix this, as I'd like layers
|
|
43
|
+
# to have sublayers, allowing us to handle vector layers more efficiently, but curently we have a
|
|
44
|
+
# deadline, and given how infrequently we hit this case, doing the naive thing for now is sufficient
|
|
45
|
+
if (abs(area.left - area.right)) > 180.0:
|
|
46
|
+
left = (-180.0 / abs_xstep) * abs_xstep
|
|
47
|
+
if left < -180.0:
|
|
48
|
+
left += abs_xstep
|
|
49
|
+
right = (180.0 / abs_xstep) * abs_xstep
|
|
50
|
+
if right > 180.0:
|
|
51
|
+
right -= abs_xstep
|
|
52
|
+
area = Area(
|
|
53
|
+
left=left,
|
|
54
|
+
right=right,
|
|
55
|
+
top=area.top,
|
|
56
|
+
bottom=area.bottom,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
super().__init__(area, scale, projection)
|
|
60
|
+
self._window = Window(
|
|
61
|
+
xoff=0,
|
|
62
|
+
yoff=0,
|
|
63
|
+
xsize=round_up_pixels((area.right - area.left) / scale.xstep, abs_xstep),
|
|
64
|
+
ysize=round_up_pixels((area.bottom - area.top) / scale.ystep, abs_ystep),
|
|
65
|
+
)
|
|
66
|
+
self._raster_xsize = self.window.xsize
|
|
67
|
+
self._raster_ysize = self.window.ysize
|
|
68
|
+
|
|
69
|
+
# see comment in read_array for why we're doing this
|
|
70
|
+
sorted_lats = [x[0] for x in self.cell_boundary]
|
|
71
|
+
sorted_lats.sort()
|
|
72
|
+
self._raster_safe_bounds = (
|
|
73
|
+
(sorted_lats[-2] / abs_ystep) * abs_ystep,
|
|
74
|
+
(sorted_lats[1] / abs_ystep) * abs_ystep,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def datatype(self) -> int:
|
|
80
|
+
return gdal.GDT_Float64
|
|
81
|
+
|
|
82
|
+
def read_array_with_window(self, xoffset: int, yoffset: int, xsize: int, ysize: int, window: Window) -> Any:
|
|
83
|
+
if (xsize <= 0) or (ysize <= 0):
|
|
84
|
+
raise ValueError("Request dimensions must be positive and non-zero")
|
|
85
|
+
|
|
86
|
+
# We have two paths: one for the common case where the hex cell doesn't cross 180˚ longitude,
|
|
87
|
+
# and another case for where it does
|
|
88
|
+
max_width_projection = self.area.right - self.area.left
|
|
89
|
+
if max_width_projection < 180:
|
|
90
|
+
|
|
91
|
+
target_window = Window(
|
|
92
|
+
window.xoff + xoffset,
|
|
93
|
+
window.yoff + yoffset,
|
|
94
|
+
xsize,
|
|
95
|
+
ysize
|
|
96
|
+
)
|
|
97
|
+
source_window = Window(
|
|
98
|
+
xoff=0,
|
|
99
|
+
yoff=0,
|
|
100
|
+
xsize=self._raster_xsize,
|
|
101
|
+
ysize=self._raster_ysize,
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
intersection = Window.find_intersection([source_window, target_window])
|
|
105
|
+
except ValueError:
|
|
106
|
+
return 0.0
|
|
107
|
+
|
|
108
|
+
subset = np.zeros((intersection.ysize, intersection.xsize))
|
|
109
|
+
|
|
110
|
+
start_x = self._active_area.left + ((intersection.xoff - self.window.xoff) * self._pixel_scale.xstep)
|
|
111
|
+
start_y = self._active_area.top + ((intersection.yoff - self.window.yoff) * self._pixel_scale.ystep)
|
|
112
|
+
|
|
113
|
+
for ypixel in range(intersection.ysize):
|
|
114
|
+
# The latlng_to_cell is quite expensive, so ideally we want to avoid
|
|
115
|
+
# calling latlng_to_cell for every pixel, though I do want to do that
|
|
116
|
+
# rather than infer from the cell_boundary, as this way I'm ensured
|
|
117
|
+
# I get an authoritative result and no pixel falls between the cracks.
|
|
118
|
+
|
|
119
|
+
# Originally I just tried the old-school way of finding the left-most
|
|
120
|
+
# and the right-most pixel per row, and then filling between, which in
|
|
121
|
+
# theory should work fine for rasterising hexagons, but in certain places
|
|
122
|
+
# the map projection distorts the edges sufficiently they become concave,
|
|
123
|
+
# and that approach lead to over filling, which caused issues with cells
|
|
124
|
+
# overlapping.
|
|
125
|
+
|
|
126
|
+
# The current implementation tries to hedge its bets by looking where the
|
|
127
|
+
# lowest and highest edges are (stored in _raster_safe_bounds) and then
|
|
128
|
+
# only doing the fill between left and right within those bounds. I suspect
|
|
129
|
+
# longer term this will need some padding, but it works for the current set
|
|
130
|
+
# of known failures in test_optimisation. As we hit more issues we should
|
|
131
|
+
# expand that test case before tweaking here. This is quite wasteful, as it's
|
|
132
|
+
# only a tiny number of cells that have convex edges, so in future it'd be
|
|
133
|
+
# interesting to see if we can infer when that is.
|
|
134
|
+
|
|
135
|
+
lat = start_y + (ypixel * self._pixel_scale.ystep)
|
|
136
|
+
|
|
137
|
+
if self._raster_safe_bounds[0] < lat < self._raster_safe_bounds[1]:
|
|
138
|
+
# In "safe" zone, try to be clever
|
|
139
|
+
left_most = intersection.xsize + 1
|
|
140
|
+
right_most = -1
|
|
141
|
+
|
|
142
|
+
for xpixel in range(intersection.xsize):
|
|
143
|
+
lng = start_x + (xpixel * self._pixel_scale.xstep)
|
|
144
|
+
this_cell = h3.latlng_to_cell(lat, lng, self.zoom)
|
|
145
|
+
if this_cell == self.cell_id:
|
|
146
|
+
left_most = xpixel
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
for xpixel in range(intersection.xsize - 1, left_most - 1, -1):
|
|
150
|
+
lng = start_x + (xpixel * self._pixel_scale.xstep)
|
|
151
|
+
this_cell = h3.latlng_to_cell(lat, lng, self.zoom)
|
|
152
|
+
if this_cell == self.cell_id:
|
|
153
|
+
right_most = xpixel
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
for xpixel in range(left_most, right_most + 1, 1):
|
|
157
|
+
subset[ypixel][xpixel] = 1.0
|
|
158
|
+
else:
|
|
159
|
+
# Not in safe zone, be diligent.
|
|
160
|
+
for xpixel in range(intersection.xsize):
|
|
161
|
+
lng = start_x + (xpixel * self._pixel_scale.xstep)
|
|
162
|
+
this_cell = h3.latlng_to_cell(lat, lng, self.zoom)
|
|
163
|
+
if this_cell == self.cell_id:
|
|
164
|
+
subset[ypixel][xpixel] = 1.0
|
|
165
|
+
|
|
166
|
+
return backend.pad(
|
|
167
|
+
backend.promote(subset),
|
|
168
|
+
(
|
|
169
|
+
(
|
|
170
|
+
(intersection.yoff - self.window.yoff) - yoffset,
|
|
171
|
+
(ysize - ((intersection.yoff - self.window.yoff) + intersection.ysize)) + yoffset,
|
|
172
|
+
),
|
|
173
|
+
(
|
|
174
|
+
(intersection.xoff - self.window.xoff) - xoffset,
|
|
175
|
+
xsize - ((intersection.xoff - self.window.xoff) + intersection.xsize) + xoffset,
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
'constant'
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
# This handles the case where the cell wraps over 180˚ longitude
|
|
182
|
+
res = np.zeros((ysize, xsize))
|
|
183
|
+
|
|
184
|
+
left = min(x[1] for x in self.cell_boundary if x[1] > 0.0)
|
|
185
|
+
right = max(x[1] for x in self.cell_boundary if x[1] < 0.0) + 360.0
|
|
186
|
+
max_width_projection = right - left
|
|
187
|
+
max_width = ceil(max_width_projection / self._pixel_scale.xstep)
|
|
188
|
+
|
|
189
|
+
for ypixel in range(yoffset, yoffset + ysize):
|
|
190
|
+
lat = self._active_area.top + (ypixel * self._pixel_scale.ystep)
|
|
191
|
+
|
|
192
|
+
for xpixel in range(xoffset, min(xoffset + xsize, max_width)):
|
|
193
|
+
lng = self._active_area.left + (xpixel * self._pixel_scale.xstep)
|
|
194
|
+
this_cell = h3.latlng_to_cell(lat, lng, self.zoom)
|
|
195
|
+
if this_cell == self.cell_id:
|
|
196
|
+
res[ypixel - yoffset][xpixel - xoffset] = 1.0
|
|
197
|
+
|
|
198
|
+
for xpixel in range(xoffset + xsize - 1, xoffset + xsize - max_width, -1):
|
|
199
|
+
lng = self._active_area.left + (xpixel * self._pixel_scale.xstep)
|
|
200
|
+
this_cell = h3.latlng_to_cell(lat, lng, self.zoom)
|
|
201
|
+
if this_cell == self.cell_id:
|
|
202
|
+
res[ypixel - yoffset][xpixel - xoffset] = 1.0
|
|
203
|
+
return backend.promote(res)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Optional, TypeVar, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from osgeo import gdal
|
|
7
|
+
|
|
8
|
+
from .. import WGS_84_PROJECTION
|
|
9
|
+
from ..window import Area, PixelScale, Window
|
|
10
|
+
from ..rounding import round_up_pixels
|
|
11
|
+
from .base import YirgacheffeLayer
|
|
12
|
+
from ..backends import backend
|
|
13
|
+
|
|
14
|
+
# Still to early to require Python 3.11 :/
|
|
15
|
+
RasterLayerT = TypeVar("RasterLayerT", bound="RasterLayer")
|
|
16
|
+
|
|
17
|
+
class InvalidRasterBand(Exception):
|
|
18
|
+
def __init__ (self, band):
|
|
19
|
+
self.band = band
|
|
20
|
+
|
|
21
|
+
class RasterLayer(YirgacheffeLayer):
|
|
22
|
+
"""Layer provides a wrapper around a gdal dataset/band that also records offset state so that
|
|
23
|
+
we can work with maps over different geographic regions but work withing a particular frame
|
|
24
|
+
of reference."""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def empty_raster_layer(
|
|
28
|
+
area: Area,
|
|
29
|
+
scale: PixelScale,
|
|
30
|
+
datatype: int,
|
|
31
|
+
filename: Optional[str]=None,
|
|
32
|
+
projection: str=WGS_84_PROJECTION,
|
|
33
|
+
name: Optional[str]=None,
|
|
34
|
+
compress: bool=True,
|
|
35
|
+
nodata: Optional[Union[float,int]]=None,
|
|
36
|
+
nbits: Optional[int]=None,
|
|
37
|
+
threads: Optional[int]=None,
|
|
38
|
+
bands: int=1
|
|
39
|
+
) -> RasterLayerT:
|
|
40
|
+
abs_xstep, abs_ystep = abs(scale.xstep), abs(scale.ystep)
|
|
41
|
+
|
|
42
|
+
# We treat the provided area as aspirational, and we need to align it to pixel boundaries
|
|
43
|
+
pixel_friendly_area = Area(
|
|
44
|
+
left=math.floor(area.left / abs_xstep) * abs_xstep,
|
|
45
|
+
right=math.ceil(area.right / abs_xstep) * abs_xstep,
|
|
46
|
+
top=math.ceil(area.top / abs_ystep) * abs_ystep,
|
|
47
|
+
bottom=math.floor(area.bottom / abs_ystep) * abs_ystep,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
options = []
|
|
51
|
+
if threads is not None:
|
|
52
|
+
options.append(f"NUM_THREADS={threads}")
|
|
53
|
+
if nbits is not None:
|
|
54
|
+
options.append(f"NBITS={nbits}")
|
|
55
|
+
|
|
56
|
+
if filename:
|
|
57
|
+
driver = gdal.GetDriverByName('GTiff')
|
|
58
|
+
options.append('BIGTIFF=YES')
|
|
59
|
+
if compress:
|
|
60
|
+
options.append('COMPRESS=LZW')
|
|
61
|
+
else:
|
|
62
|
+
options.append('COMPRESS=NONE')
|
|
63
|
+
else:
|
|
64
|
+
driver = gdal.GetDriverByName('mem')
|
|
65
|
+
filename = 'mem'
|
|
66
|
+
compress = False
|
|
67
|
+
dataset = driver.Create(
|
|
68
|
+
filename,
|
|
69
|
+
round_up_pixels((pixel_friendly_area.right - pixel_friendly_area.left) / abs_xstep, abs_xstep),
|
|
70
|
+
round_up_pixels((pixel_friendly_area.top - pixel_friendly_area.bottom) / abs_ystep, abs_ystep),
|
|
71
|
+
bands,
|
|
72
|
+
datatype,
|
|
73
|
+
options
|
|
74
|
+
)
|
|
75
|
+
dataset.SetGeoTransform([
|
|
76
|
+
pixel_friendly_area.left, scale.xstep, 0.0, pixel_friendly_area.top, 0.0, scale.ystep
|
|
77
|
+
])
|
|
78
|
+
dataset.SetProjection(projection)
|
|
79
|
+
if nodata is not None:
|
|
80
|
+
dataset.GetRasterBand(1).SetNoDataValue(nodata)
|
|
81
|
+
return RasterLayer(dataset, name=name)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def empty_raster_layer_like(
|
|
85
|
+
layer: Any,
|
|
86
|
+
filename: Optional[str]=None,
|
|
87
|
+
area: Optional[Area]=None,
|
|
88
|
+
datatype: Optional[int]=None,
|
|
89
|
+
compress: bool=True,
|
|
90
|
+
nodata: Optional[Union[float,int]]=None,
|
|
91
|
+
nbits: Optional[int]=None,
|
|
92
|
+
threads: Optional[int]=None,
|
|
93
|
+
bands: int=1
|
|
94
|
+
) -> RasterLayerT:
|
|
95
|
+
width = layer.window.xsize
|
|
96
|
+
height = layer.window.ysize
|
|
97
|
+
if area is None:
|
|
98
|
+
area = layer.area
|
|
99
|
+
assert area is not None
|
|
100
|
+
|
|
101
|
+
scale = layer.pixel_scale
|
|
102
|
+
if scale is None:
|
|
103
|
+
raise ValueError("Can not work out area without explicit pixel scale")
|
|
104
|
+
abs_xstep, abs_ystep = abs(scale.xstep), abs(scale.ystep)
|
|
105
|
+
width = round_up_pixels((area.right - area.left) / abs_xstep, abs_xstep)
|
|
106
|
+
height = round_up_pixels((area.top - area.bottom) / abs_ystep, abs_ystep)
|
|
107
|
+
geo_transform = (
|
|
108
|
+
area.left, scale.xstep, 0.0, area.top, 0.0, scale.ystep
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
options = []
|
|
112
|
+
if threads is not None:
|
|
113
|
+
options.append(f"NUM_THREADS={threads}")
|
|
114
|
+
if nbits is not None:
|
|
115
|
+
options.append(f"NBITS={nbits}")
|
|
116
|
+
|
|
117
|
+
if filename:
|
|
118
|
+
driver = gdal.GetDriverByName('GTiff')
|
|
119
|
+
options.append('BIGTIFF=YES')
|
|
120
|
+
if compress:
|
|
121
|
+
options.append('COMPRESS=LZW')
|
|
122
|
+
else:
|
|
123
|
+
options.append('COMPRESS=NONE')
|
|
124
|
+
else:
|
|
125
|
+
driver = gdal.GetDriverByName('mem')
|
|
126
|
+
filename = 'mem'
|
|
127
|
+
compress = False
|
|
128
|
+
dataset = driver.Create(
|
|
129
|
+
filename,
|
|
130
|
+
width,
|
|
131
|
+
height,
|
|
132
|
+
bands,
|
|
133
|
+
datatype if datatype is not None else layer.datatype,
|
|
134
|
+
options,
|
|
135
|
+
)
|
|
136
|
+
dataset.SetGeoTransform(geo_transform)
|
|
137
|
+
dataset.SetProjection(layer.projection)
|
|
138
|
+
if nodata is not None:
|
|
139
|
+
dataset.GetRasterBand(1).SetNoDataValue(nodata)
|
|
140
|
+
|
|
141
|
+
return RasterLayer(dataset)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def scaled_raster_from_raster(
|
|
145
|
+
cls,
|
|
146
|
+
source: RasterLayerT,
|
|
147
|
+
new_pixel_scale: PixelScale,
|
|
148
|
+
filename: Optional[str]=None,
|
|
149
|
+
compress: bool=True,
|
|
150
|
+
algorithm: int=gdal.GRA_NearestNeighbour,
|
|
151
|
+
) -> RasterLayerT:
|
|
152
|
+
source_dataset = source._dataset
|
|
153
|
+
old_pixel_scale = source.pixel_scale
|
|
154
|
+
assert old_pixel_scale
|
|
155
|
+
|
|
156
|
+
x_scale = old_pixel_scale.xstep / new_pixel_scale.xstep
|
|
157
|
+
y_scale = old_pixel_scale.ystep / new_pixel_scale.ystep
|
|
158
|
+
new_width = round_up_pixels(source_dataset.RasterXSize * x_scale,
|
|
159
|
+
abs(new_pixel_scale.xstep))
|
|
160
|
+
new_height = round_up_pixels(source_dataset.RasterYSize * y_scale,
|
|
161
|
+
abs(new_pixel_scale.ystep))
|
|
162
|
+
|
|
163
|
+
# in yirgacheffe we like to have things aligned to the pixel_scale, so work
|
|
164
|
+
# out new top left corner
|
|
165
|
+
new_left = math.floor((source.area.left / new_pixel_scale.xstep)) * new_pixel_scale.xstep
|
|
166
|
+
new_top = math.ceil((source.area.top / new_pixel_scale.ystep)) * new_pixel_scale.ystep
|
|
167
|
+
|
|
168
|
+
# now build a target dataset
|
|
169
|
+
options = []
|
|
170
|
+
if filename:
|
|
171
|
+
driver = gdal.GetDriverByName('GTiff')
|
|
172
|
+
options.append('BIGTIFF=YES')
|
|
173
|
+
if compress:
|
|
174
|
+
options.append('COMPRESS=LZW')
|
|
175
|
+
else:
|
|
176
|
+
driver = gdal.GetDriverByName('mem')
|
|
177
|
+
filename = 'mem'
|
|
178
|
+
compress = False
|
|
179
|
+
dataset = driver.Create(
|
|
180
|
+
filename,
|
|
181
|
+
new_width,
|
|
182
|
+
new_height,
|
|
183
|
+
1,
|
|
184
|
+
source.datatype,
|
|
185
|
+
options
|
|
186
|
+
)
|
|
187
|
+
dataset.SetGeoTransform((
|
|
188
|
+
new_left, new_pixel_scale.xstep, 0.0,
|
|
189
|
+
new_top, 0.0, new_pixel_scale.ystep
|
|
190
|
+
))
|
|
191
|
+
dataset.SetProjection(source_dataset.GetProjection())
|
|
192
|
+
|
|
193
|
+
# now use gdal to do the reprojection
|
|
194
|
+
gdal.ReprojectImage(source_dataset, dataset, eResampleAlg=algorithm)
|
|
195
|
+
|
|
196
|
+
return RasterLayer(dataset)
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def layer_from_file(cls, filename: str, band: int = 1) -> RasterLayerT:
|
|
200
|
+
try:
|
|
201
|
+
dataset = gdal.Open(filename, gdal.GA_ReadOnly)
|
|
202
|
+
except RuntimeError as exc:
|
|
203
|
+
# With exceptions on GDAL now returns the wrong (IMHO) exception
|
|
204
|
+
raise FileNotFoundError(filename) from exc
|
|
205
|
+
try:
|
|
206
|
+
_ = dataset.GetRasterBand(band)
|
|
207
|
+
except RuntimeError as exc:
|
|
208
|
+
raise InvalidRasterBand(band) from exc
|
|
209
|
+
return cls(dataset, filename, band)
|
|
210
|
+
|
|
211
|
+
def __init__(self, dataset: gdal.Dataset, name: Optional[str] = None, band: int = 1):
|
|
212
|
+
if not dataset:
|
|
213
|
+
raise ValueError("None is not a valid dataset")
|
|
214
|
+
|
|
215
|
+
transform = dataset.GetGeoTransform()
|
|
216
|
+
scale = PixelScale(transform[1], transform[5])
|
|
217
|
+
area = Area(
|
|
218
|
+
left=transform[0],
|
|
219
|
+
top=transform[3],
|
|
220
|
+
right=transform[0] + (dataset.RasterXSize * scale.xstep),
|
|
221
|
+
bottom=transform[3] + (dataset.RasterYSize * scale.ystep),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
super().__init__(
|
|
225
|
+
area,
|
|
226
|
+
scale,
|
|
227
|
+
dataset.GetProjection(),
|
|
228
|
+
name=name
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# The constructor works out the window from the area
|
|
232
|
+
# so sanity check that the calculated window matches the
|
|
233
|
+
# dataset's dimensions
|
|
234
|
+
assert self.window == Window(0, 0, dataset.RasterXSize, dataset.RasterYSize)
|
|
235
|
+
|
|
236
|
+
self._dataset = dataset
|
|
237
|
+
self._dataset_path = dataset.GetDescription()
|
|
238
|
+
self._band = band
|
|
239
|
+
self._raster_xsize = dataset.RasterXSize
|
|
240
|
+
self._raster_ysize = dataset.RasterYSize
|
|
241
|
+
|
|
242
|
+
def close(self):
|
|
243
|
+
try:
|
|
244
|
+
if self._dataset:
|
|
245
|
+
try:
|
|
246
|
+
self._dataset.Close()
|
|
247
|
+
except AttributeError:
|
|
248
|
+
pass
|
|
249
|
+
del self._dataset
|
|
250
|
+
except AttributeError:
|
|
251
|
+
# Don't error if close was already called
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def __getstate__(self) -> object:
|
|
255
|
+
# Only support pickling on file backed layers (ideally read only ones...)
|
|
256
|
+
if not os.path.isfile(self._dataset_path):
|
|
257
|
+
raise ValueError("Can not pickle layer that is not file backed.")
|
|
258
|
+
odict = self.__dict__.copy()
|
|
259
|
+
self._park()
|
|
260
|
+
del odict['_dataset']
|
|
261
|
+
return odict
|
|
262
|
+
|
|
263
|
+
def __setstate__(self, state):
|
|
264
|
+
self.__dict__.update(state)
|
|
265
|
+
self._unpark()
|
|
266
|
+
|
|
267
|
+
def _park(self):
|
|
268
|
+
try:
|
|
269
|
+
self._dataset.Close()
|
|
270
|
+
except AttributeError:
|
|
271
|
+
pass
|
|
272
|
+
self._dataset = None
|
|
273
|
+
|
|
274
|
+
def _unpark(self):
|
|
275
|
+
if getattr(self, "_dataset", None) is None:
|
|
276
|
+
try:
|
|
277
|
+
self._dataset = gdal.Open(self._dataset_path)
|
|
278
|
+
except RuntimeError as exc:
|
|
279
|
+
raise FileNotFoundError(f"Failed to open pickled raster {self._dataset_path}") from exc
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def datatype(self) -> int:
|
|
283
|
+
if self._dataset is None:
|
|
284
|
+
self._unpark()
|
|
285
|
+
assert self._dataset
|
|
286
|
+
return self._dataset.GetRasterBand(1).DataType
|
|
287
|
+
|
|
288
|
+
def read_array_with_window(self, xoffset: int, yoffset: int, xsize: int, ysize: int, window: Window) -> Any:
|
|
289
|
+
if self._dataset is None:
|
|
290
|
+
self._unpark()
|
|
291
|
+
assert self._dataset
|
|
292
|
+
|
|
293
|
+
if (xsize <= 0) or (ysize <= 0):
|
|
294
|
+
raise ValueError("Request dimensions must be positive and non-zero")
|
|
295
|
+
|
|
296
|
+
# if we're dealing with an intersection, we can just read the data directly,
|
|
297
|
+
# otherwise we need to read the data into another array with suitable padding
|
|
298
|
+
target_window = Window(
|
|
299
|
+
window.xoff + xoffset,
|
|
300
|
+
window.yoff + yoffset,
|
|
301
|
+
xsize,
|
|
302
|
+
ysize
|
|
303
|
+
)
|
|
304
|
+
source_window = Window(
|
|
305
|
+
xoff=0,
|
|
306
|
+
yoff=0,
|
|
307
|
+
xsize=self._raster_xsize,
|
|
308
|
+
ysize=self._raster_ysize,
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
intersection = Window.find_intersection([source_window, target_window])
|
|
312
|
+
except ValueError:
|
|
313
|
+
return backend.zeros((ysize, xsize))
|
|
314
|
+
|
|
315
|
+
if target_window == intersection:
|
|
316
|
+
# The target window is a subset of or equal to the source, so we can just ask for the data
|
|
317
|
+
data = backend.promote(self._dataset.GetRasterBand(self._band).ReadAsArray(*intersection.as_array_args))
|
|
318
|
+
return data
|
|
319
|
+
else:
|
|
320
|
+
# We should read the intersection from the array, and the rest should be zeros
|
|
321
|
+
subset = backend.promote(self._dataset.GetRasterBand(self._band).ReadAsArray(*intersection.as_array_args))
|
|
322
|
+
region = np.array((
|
|
323
|
+
(
|
|
324
|
+
(intersection.yoff - window.yoff) - yoffset,
|
|
325
|
+
(ysize - ((intersection.yoff - window.yoff) + intersection.ysize)) + yoffset,
|
|
326
|
+
),
|
|
327
|
+
(
|
|
328
|
+
(intersection.xoff - window.xoff) - xoffset,
|
|
329
|
+
xsize - ((intersection.xoff - window.xoff) + intersection.xsize) + xoffset,
|
|
330
|
+
)
|
|
331
|
+
)).astype(int)
|
|
332
|
+
data = backend.pad(subset, region, mode='constant')
|
|
333
|
+
return data
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from math import floor, ceil
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from skimage import transform
|
|
5
|
+
|
|
6
|
+
from ..window import PixelScale, Window
|
|
7
|
+
from .rasters import RasterLayer, YirgacheffeLayer
|
|
8
|
+
from ..backends import backend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RescaledRasterLayer(YirgacheffeLayer):
|
|
12
|
+
"""RescaledRaster dynamically rescales a raster, so to you to work with multiple layers at
|
|
13
|
+
different scales without having to store unnecessary data. """
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def layer_from_file(
|
|
17
|
+
cls,
|
|
18
|
+
filename: str,
|
|
19
|
+
pixel_scale: PixelScale,
|
|
20
|
+
band: int = 1,
|
|
21
|
+
nearest_neighbour: bool = True,
|
|
22
|
+
):
|
|
23
|
+
src = RasterLayer.layer_from_file(filename, band=band)
|
|
24
|
+
return RescaledRasterLayer(src, pixel_scale, nearest_neighbour, src.name)
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
src: RasterLayer,
|
|
29
|
+
pixel_scale: PixelScale,
|
|
30
|
+
nearest_neighbour: bool = True,
|
|
31
|
+
name: Optional[str] = None,
|
|
32
|
+
):
|
|
33
|
+
super().__init__(
|
|
34
|
+
src.area,
|
|
35
|
+
pixel_scale=pixel_scale,
|
|
36
|
+
projection=src.projection,
|
|
37
|
+
name=name
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self._src = src
|
|
41
|
+
self._nearest_neighbour = nearest_neighbour
|
|
42
|
+
|
|
43
|
+
src_pixel_scale = src.pixel_scale
|
|
44
|
+
assert src_pixel_scale # from raster we should always have one
|
|
45
|
+
|
|
46
|
+
self._x_scale = src_pixel_scale.xstep / pixel_scale.xstep
|
|
47
|
+
self._y_scale = src_pixel_scale.ystep / pixel_scale.ystep
|
|
48
|
+
|
|
49
|
+
def close(self):
|
|
50
|
+
self._src.close()
|
|
51
|
+
|
|
52
|
+
def _park(self):
|
|
53
|
+
self._src._park()
|
|
54
|
+
|
|
55
|
+
def _unpark(self):
|
|
56
|
+
self._src._unpark()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def datatype(self) -> int:
|
|
60
|
+
return self._src.datatype
|
|
61
|
+
|
|
62
|
+
def read_array_with_window(self, xoffset: int, yoffset: int, xsize: int, ysize: int, window: Window) -> Any:
|
|
63
|
+
|
|
64
|
+
# to avoid aliasing issues, we try to scale to the nearest pixel
|
|
65
|
+
# and recrop when scaling bigger
|
|
66
|
+
|
|
67
|
+
xoffset = xoffset + window.xoff
|
|
68
|
+
yoffset = yoffset + window.yoff
|
|
69
|
+
|
|
70
|
+
src_x_offset = floor(xoffset / self._x_scale)
|
|
71
|
+
src_y_offset = floor(yoffset / self._y_scale)
|
|
72
|
+
|
|
73
|
+
diff_x = floor(((xoffset / self._x_scale) - src_x_offset) * self._x_scale)
|
|
74
|
+
diff_y = floor(((yoffset / self._x_scale) - src_y_offset) * self._x_scale)
|
|
75
|
+
|
|
76
|
+
src_x_width = ceil((xsize + diff_x) / self._x_scale)
|
|
77
|
+
src_y_width = ceil((ysize + diff_y) / self._y_scale)
|
|
78
|
+
|
|
79
|
+
# Get the matching src data
|
|
80
|
+
src_data = backend.demote_array(self._src.read_array(
|
|
81
|
+
src_x_offset,
|
|
82
|
+
src_y_offset,
|
|
83
|
+
src_x_width,
|
|
84
|
+
src_y_width
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
scaled = transform.resize(
|
|
88
|
+
src_data,
|
|
89
|
+
(src_y_width * self._y_scale, src_x_width * self._x_scale),
|
|
90
|
+
order=(0 if self._nearest_neighbour else 1),
|
|
91
|
+
anti_aliasing=(not self._nearest_neighbour)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return backend.promote(scaled[diff_y:(diff_y + ysize),diff_x:(diff_x + xsize)])
|