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,265 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Any, List, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from ..operators import LayerMathMixin
|
|
5
|
+
from ..rounding import almost_equal, are_pixel_scales_equal_enough, round_up_pixels, round_down_pixels
|
|
6
|
+
from ..window import Area, PixelScale, Window
|
|
7
|
+
|
|
8
|
+
class YirgacheffeLayer(LayerMathMixin):
|
|
9
|
+
"""The common base class for the different layer types. Most still inherit from RasterLayer as deep down
|
|
10
|
+
they end up as pixels, but this is a start to make other layers that don't need to rasterize not have
|
|
11
|
+
to carry all that baggage."""
|
|
12
|
+
|
|
13
|
+
def __init__(self,
|
|
14
|
+
area: Area,
|
|
15
|
+
pixel_scale: Optional[PixelScale],
|
|
16
|
+
projection: str,
|
|
17
|
+
name: Optional[str] = None
|
|
18
|
+
):
|
|
19
|
+
self._pixel_scale = pixel_scale
|
|
20
|
+
self._underlying_area = area
|
|
21
|
+
self._active_area = area
|
|
22
|
+
self._projection = projection
|
|
23
|
+
self._window: Optional[Window] = None
|
|
24
|
+
self.name = name
|
|
25
|
+
|
|
26
|
+
self.reset_window()
|
|
27
|
+
|
|
28
|
+
def close(self):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
35
|
+
self.close()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def datatype(self) -> int:
|
|
39
|
+
raise NotImplementedError("Must be overridden by subclass")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def projection(self) -> str:
|
|
43
|
+
return self._projection
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def pixel_scale(self) -> Optional[PixelScale]:
|
|
47
|
+
return self._pixel_scale
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def area(self) -> Area:
|
|
51
|
+
return self._active_area
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def window(self) -> Window:
|
|
55
|
+
if self._window is None:
|
|
56
|
+
raise AttributeError("Layer has no window")
|
|
57
|
+
return self._window
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def find_intersection(layers: List) -> Area:
|
|
61
|
+
if not layers:
|
|
62
|
+
raise ValueError("Expected list of layers")
|
|
63
|
+
|
|
64
|
+
# This only makes sense (currently) if all layers
|
|
65
|
+
# have the same pixel pitch (modulo desired accuracy)
|
|
66
|
+
if not are_pixel_scales_equal_enough([x.pixel_scale for x in layers]):
|
|
67
|
+
raise ValueError("Not all layers are at the same pixel scale")
|
|
68
|
+
|
|
69
|
+
intersection = Area(
|
|
70
|
+
left=max(x._underlying_area.left for x in layers),
|
|
71
|
+
top=min(x._underlying_area.top for x in layers),
|
|
72
|
+
right=min(x._underlying_area.right for x in layers),
|
|
73
|
+
bottom=max(x._underlying_area.bottom for x in layers)
|
|
74
|
+
)
|
|
75
|
+
if (intersection.left >= intersection.right) or (intersection.bottom >= intersection.top):
|
|
76
|
+
raise ValueError('No intersection possible')
|
|
77
|
+
return intersection
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def find_union(layers: List) -> Area:
|
|
81
|
+
if not layers:
|
|
82
|
+
raise ValueError("Expected list of layers")
|
|
83
|
+
|
|
84
|
+
# This only makes sense (currently) if all layers
|
|
85
|
+
# have the same pixel pitch (modulo desired accuracy)
|
|
86
|
+
if not are_pixel_scales_equal_enough([x.pixel_scale for x in layers]):
|
|
87
|
+
raise ValueError("Not all layers are at the same pixel scale")
|
|
88
|
+
|
|
89
|
+
return Area(
|
|
90
|
+
left=min(x._underlying_area.left for x in layers),
|
|
91
|
+
top=max(x._underlying_area.top for x in layers),
|
|
92
|
+
right=max(x._underlying_area.right for x in layers),
|
|
93
|
+
bottom=min(x._underlying_area.bottom for x in layers)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def geo_transform(self) -> Tuple[float, float, float, float, float, float]:
|
|
98
|
+
if self._pixel_scale is None:
|
|
99
|
+
raise ValueError("No geo transform for layers without explicit pixel scale")
|
|
100
|
+
return (
|
|
101
|
+
self._active_area.left, self._pixel_scale.xstep, 0.0,
|
|
102
|
+
self._active_area.top, 0.0, self._pixel_scale.ystep
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def check_pixel_scale(self, scale: PixelScale) -> bool:
|
|
106
|
+
our_scale = self.pixel_scale
|
|
107
|
+
if our_scale is None:
|
|
108
|
+
raise ValueError("No check for layers without explicit pixel scale")
|
|
109
|
+
return almost_equal(our_scale.xstep, scale.xstep) and \
|
|
110
|
+
almost_equal(our_scale.ystep, scale.ystep)
|
|
111
|
+
|
|
112
|
+
def set_window_for_intersection(self, new_area: Area) -> None:
|
|
113
|
+
if self._pixel_scale is None:
|
|
114
|
+
raise ValueError("Can not set Window without explicit pixel scale")
|
|
115
|
+
|
|
116
|
+
new_window = Window(
|
|
117
|
+
xoff=round_down_pixels((new_area.left - self._underlying_area.left) / self._pixel_scale.xstep,
|
|
118
|
+
self._pixel_scale.xstep),
|
|
119
|
+
yoff=round_down_pixels((self._underlying_area.top - new_area.top) / (self._pixel_scale.ystep * -1.0),
|
|
120
|
+
self._pixel_scale.ystep * -1.0),
|
|
121
|
+
xsize=round_up_pixels(
|
|
122
|
+
(new_area.right - new_area.left) / self._pixel_scale.xstep,
|
|
123
|
+
self._pixel_scale.xstep
|
|
124
|
+
),
|
|
125
|
+
ysize=round_up_pixels(
|
|
126
|
+
(new_area.top - new_area.bottom) / (self._pixel_scale.ystep * -1.0),
|
|
127
|
+
(self._pixel_scale.ystep * -1.0)
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
if (new_window.xoff < 0) or (new_window.yoff < 0):
|
|
131
|
+
raise ValueError('Window has negative offset')
|
|
132
|
+
# If there is an underlying raster for this layer, do a sanity check
|
|
133
|
+
try:
|
|
134
|
+
if ((new_window.xoff + new_window.xsize) > self._raster_xsize) or \
|
|
135
|
+
((new_window.yoff + new_window.ysize) > self._raster_ysize):
|
|
136
|
+
raise ValueError(f'Window is bigger than dataset: raster is {self._raster_xsize}x{self._raster_ysize}'\
|
|
137
|
+
f', new window is {new_window.xsize - new_window.xoff}x{new_window.ysize - new_window.yoff}')
|
|
138
|
+
except AttributeError:
|
|
139
|
+
pass
|
|
140
|
+
self._window = new_window
|
|
141
|
+
self._active_area = new_area
|
|
142
|
+
|
|
143
|
+
def set_window_for_union(self, new_area: Area) -> None:
|
|
144
|
+
if self._pixel_scale is None:
|
|
145
|
+
raise ValueError("Can not set Window without explicit pixel scale")
|
|
146
|
+
|
|
147
|
+
new_window = Window(
|
|
148
|
+
xoff=round_down_pixels((new_area.left - self._underlying_area.left) / self._pixel_scale.xstep,
|
|
149
|
+
self._pixel_scale.xstep),
|
|
150
|
+
yoff=round_down_pixels((self._underlying_area.top - new_area.top) / (self._pixel_scale.ystep * -1.0),
|
|
151
|
+
self._pixel_scale.ystep * -1.0),
|
|
152
|
+
xsize=round_up_pixels(
|
|
153
|
+
(new_area.right - new_area.left) / self._pixel_scale.xstep,
|
|
154
|
+
self._pixel_scale.xstep
|
|
155
|
+
),
|
|
156
|
+
ysize=round_up_pixels(
|
|
157
|
+
(new_area.top - new_area.bottom) / (self._pixel_scale.ystep * -1.0),
|
|
158
|
+
(self._pixel_scale.ystep * -1.0)
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
if (new_window.xoff > 0) or (new_window.yoff > 0):
|
|
162
|
+
raise ValueError('Window has positive offset')
|
|
163
|
+
# If there is an underlying raster for this layer, do a sanity check
|
|
164
|
+
try:
|
|
165
|
+
if ((new_window.xsize - new_window.xoff) < self._raster_xsize) or \
|
|
166
|
+
((new_window.ysize - new_window.yoff) < self._raster_ysize):
|
|
167
|
+
raise ValueError(f'Window is smaller than dataset: raster is {self._raster_xsize}x{self._raster_ysize}'\
|
|
168
|
+
f', new window is {new_window.xsize - new_window.xoff}x{new_window.ysize - new_window.yoff}')
|
|
169
|
+
except AttributeError:
|
|
170
|
+
pass
|
|
171
|
+
self._window = new_window
|
|
172
|
+
self._active_area = new_area
|
|
173
|
+
|
|
174
|
+
def reset_window(self):
|
|
175
|
+
self._active_area = self._underlying_area
|
|
176
|
+
if self._pixel_scale:
|
|
177
|
+
abs_xstep, abs_ystep = abs(self._pixel_scale.xstep), abs(self._pixel_scale.ystep)
|
|
178
|
+
self._window = Window(
|
|
179
|
+
xoff=0,
|
|
180
|
+
yoff=0,
|
|
181
|
+
xsize=round_up_pixels((self.area.right - self.area.left) / self._pixel_scale.xstep, abs_xstep),
|
|
182
|
+
ysize=round_up_pixels((self.area.bottom - self.area.top) / self._pixel_scale.ystep, abs_ystep),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def offset_window_by_pixels(self, offset: int):
|
|
186
|
+
"""Grows (if pixels is positive) or shrinks (if pixels is negative) the window for the layer."""
|
|
187
|
+
if offset == 0:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if offset < 0:
|
|
191
|
+
if (offset * -2 >= self.window.xsize) or (offset * -2 >= self.window.ysize):
|
|
192
|
+
raise ValueError(f"Can not shrink window by {offset}, would make size 0 or fewer pixels.")
|
|
193
|
+
|
|
194
|
+
new_window = Window(
|
|
195
|
+
xoff=self.window.xoff - offset,
|
|
196
|
+
yoff=self.window.yoff - offset,
|
|
197
|
+
xsize=self.window.xsize + (2 * offset),
|
|
198
|
+
ysize=self.window.ysize + (2 * offset),
|
|
199
|
+
)
|
|
200
|
+
scale = self.pixel_scale
|
|
201
|
+
if scale is None:
|
|
202
|
+
raise ValueError("Can not offset Window without explicit pixel scale")
|
|
203
|
+
|
|
204
|
+
# Note we can't assume that we weren't already on an intersection when making the offset!
|
|
205
|
+
# But remember that window is always relative to underlying area, and new_window
|
|
206
|
+
# here is based off the existing window
|
|
207
|
+
new_left = self._underlying_area.left + (new_window.xoff * scale.xstep)
|
|
208
|
+
new_top = self._underlying_area.top + (new_window.yoff * scale.ystep)
|
|
209
|
+
new_area = Area(
|
|
210
|
+
left=new_left,
|
|
211
|
+
top=new_top,
|
|
212
|
+
right=new_left + (new_window.xsize * scale.xstep),
|
|
213
|
+
bottom=new_top + (new_window.ysize * scale.ystep)
|
|
214
|
+
)
|
|
215
|
+
self._window = new_window
|
|
216
|
+
self._active_area = new_area
|
|
217
|
+
|
|
218
|
+
def read_array_with_window(self, _x: int, _y: int, _xsize: int, _ysize: int, window: Window) -> Any:
|
|
219
|
+
raise NotImplementedError("Must be overridden by subclass")
|
|
220
|
+
|
|
221
|
+
def read_array_for_area(self, target_area: Area, x: int, y: int, width: int, height: int) -> Any:
|
|
222
|
+
|
|
223
|
+
target_window = Window(
|
|
224
|
+
xoff=round_down_pixels((target_area.left - self._underlying_area.left) / self._pixel_scale.xstep,
|
|
225
|
+
self._pixel_scale.xstep),
|
|
226
|
+
yoff=round_down_pixels((self._underlying_area.top - target_area.top) / (self._pixel_scale.ystep * -1.0),
|
|
227
|
+
self._pixel_scale.ystep * -1.0),
|
|
228
|
+
xsize=round_up_pixels(
|
|
229
|
+
(target_area.right - target_area.left) / self._pixel_scale.xstep,
|
|
230
|
+
self._pixel_scale.xstep
|
|
231
|
+
),
|
|
232
|
+
ysize=round_up_pixels(
|
|
233
|
+
(target_area.top - target_area.bottom) / (self._pixel_scale.ystep * -1.0),
|
|
234
|
+
(self._pixel_scale.ystep * -1.0)
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
return self.read_array_with_window(x, y, width, height, target_window)
|
|
238
|
+
|
|
239
|
+
def read_array(self, x: int, y: int, width: int, height: int) -> Any:
|
|
240
|
+
return self.read_array_with_window(x, y, width, height, self.window)
|
|
241
|
+
|
|
242
|
+
def latlng_for_pixel(self, x_coord: int, y_coord: int) -> Tuple[float,float]:
|
|
243
|
+
"""Get geo coords for pixel. This is relative to the set view window."""
|
|
244
|
+
if "WGS 84" not in self.projection:
|
|
245
|
+
raise NotImplementedError("Not yet supported for other projections")
|
|
246
|
+
pixel_scale = self.pixel_scale
|
|
247
|
+
if pixel_scale is None:
|
|
248
|
+
raise ValueError("Layer has no pixel scale")
|
|
249
|
+
return (
|
|
250
|
+
(y_coord * pixel_scale.ystep) + self.area.top,
|
|
251
|
+
(x_coord * pixel_scale.xstep) + self.area.left
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def pixel_for_latlng(self, lat: float, lng: float) -> Tuple[int,int]:
|
|
255
|
+
"""Get pixel for geo coords. This is relative to the set view window.
|
|
256
|
+
Result is rounded down to nearest pixel."""
|
|
257
|
+
if "WGS 84" not in self.projection:
|
|
258
|
+
raise NotImplementedError("Not yet supported for other projections")
|
|
259
|
+
pixel_scale = self.pixel_scale
|
|
260
|
+
if pixel_scale is None:
|
|
261
|
+
raise ValueError("Layer has no pixel scale")
|
|
262
|
+
return (
|
|
263
|
+
round_down_pixels((lng - self.area.left) / pixel_scale.xstep, abs(pixel_scale.xstep)),
|
|
264
|
+
round_down_pixels((lat - self.area.top) / pixel_scale.ystep, abs(pixel_scale.ystep)),
|
|
265
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any, Union
|
|
2
|
+
|
|
3
|
+
from osgeo import gdal
|
|
4
|
+
|
|
5
|
+
from ..window import Area, PixelScale, Window
|
|
6
|
+
from .base import YirgacheffeLayer
|
|
7
|
+
from ..backends import backend
|
|
8
|
+
from .. import WGS_84_PROJECTION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConstantLayer(YirgacheffeLayer):
|
|
12
|
+
"""This is a layer that will return the identity value - can be used when an input layer is
|
|
13
|
+
missing (e.g., area) without having the calculation full of branches."""
|
|
14
|
+
def __init__(self, value: Union[int,float]): # pylint: disable=W0231
|
|
15
|
+
area = Area(
|
|
16
|
+
left = -180.0,
|
|
17
|
+
top = 90.0,
|
|
18
|
+
right = 180.0,
|
|
19
|
+
bottom = -90.0
|
|
20
|
+
)
|
|
21
|
+
super().__init__(area, None, WGS_84_PROJECTION)
|
|
22
|
+
self.value = float(value)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def datatype(self) -> int:
|
|
26
|
+
return gdal.GDT_Float64
|
|
27
|
+
|
|
28
|
+
def check_pixel_scale(self, _scale: PixelScale) -> bool:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def set_window_for_intersection(self, _intersection: Area) -> None:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def read_array(self, _x: int, _y: int, width: int, height: int) -> Any:
|
|
35
|
+
return backend.full((height, width), self.value)
|
|
36
|
+
|
|
37
|
+
def read_array_with_window(self, _x: int, _y: int, width: int, height: int, _window: Window) -> Any:
|
|
38
|
+
return backend.full((height, width), self.value)
|
|
39
|
+
|
|
40
|
+
def read_array_for_area(self, _target_area: Area, x: int, y: int, width: int, height: int) -> Any:
|
|
41
|
+
return self.read_array(x, y, width, height)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import glob
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, List, Optional, TypeVar
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..rounding import are_pixel_scales_equal_enough, round_down_pixels
|
|
9
|
+
from ..window import Area, Window
|
|
10
|
+
from .base import YirgacheffeLayer
|
|
11
|
+
from .rasters import RasterLayer
|
|
12
|
+
from ..backends import backend
|
|
13
|
+
|
|
14
|
+
GroupLayerT = TypeVar("GroupLayerT", bound="GroupLayer")
|
|
15
|
+
|
|
16
|
+
class GroupLayerEmpty(ValueError):
|
|
17
|
+
def __init__(self, msg):
|
|
18
|
+
self.msg = msg
|
|
19
|
+
|
|
20
|
+
class GroupLayer(YirgacheffeLayer):
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def layer_from_directory(
|
|
24
|
+
cls,
|
|
25
|
+
directory_path: str,
|
|
26
|
+
name: Optional[str] = None,
|
|
27
|
+
matching: str = "*.tif"
|
|
28
|
+
) -> GroupLayerT:
|
|
29
|
+
if directory_path is None:
|
|
30
|
+
raise ValueError("Directory path is None")
|
|
31
|
+
files = [os.path.join(directory_path, x) for x in glob.glob(matching, root_dir=directory_path)]
|
|
32
|
+
if len(files) < 1:
|
|
33
|
+
raise GroupLayerEmpty(directory_path)
|
|
34
|
+
return cls.layer_from_files(files, name)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def layer_from_files(cls, filenames: List[str], name: Optional[str] = None) -> GroupLayerT:
|
|
38
|
+
if filenames is None:
|
|
39
|
+
raise ValueError("filenames argument is None")
|
|
40
|
+
if len(filenames) < 1:
|
|
41
|
+
raise GroupLayerEmpty("No files found")
|
|
42
|
+
rasters = [RasterLayer.layer_from_file(x) for x in filenames]
|
|
43
|
+
return cls(rasters, name)
|
|
44
|
+
|
|
45
|
+
def __init__(self, layers: List[YirgacheffeLayer], name: Optional[str] = None) -> None:
|
|
46
|
+
if not layers:
|
|
47
|
+
raise GroupLayerEmpty("Expected one or more layers")
|
|
48
|
+
if not are_pixel_scales_equal_enough([x.pixel_scale for x in layers]):
|
|
49
|
+
raise ValueError("Not all layers are at the same pixel scale")
|
|
50
|
+
if not all(x.projection == layers[0].projection for x in layers):
|
|
51
|
+
raise ValueError("Not all layers are the same projection")
|
|
52
|
+
for layer in layers:
|
|
53
|
+
if layer._active_area != layer._underlying_area:
|
|
54
|
+
raise ValueError("Layers can not currently be constrained")
|
|
55
|
+
|
|
56
|
+
# area/window are superset of all tiles
|
|
57
|
+
union = YirgacheffeLayer.find_union(layers)
|
|
58
|
+
super().__init__(union, layers[0].pixel_scale, layers[0].projection, name=name)
|
|
59
|
+
|
|
60
|
+
# We store them in reverse order so that from the user's perspective
|
|
61
|
+
# the first layer in the list will be the most important in terms
|
|
62
|
+
# over overlapping.
|
|
63
|
+
self._underlying_layers = copy.copy(layers)
|
|
64
|
+
self._underlying_layers.reverse()
|
|
65
|
+
self.layers = self._underlying_layers
|
|
66
|
+
|
|
67
|
+
def _park(self):
|
|
68
|
+
for layer in self.layers:
|
|
69
|
+
try:
|
|
70
|
+
layer._park()
|
|
71
|
+
except AttributeError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def datatype(self):
|
|
76
|
+
return self.layers[0].datatype
|
|
77
|
+
|
|
78
|
+
def set_window_for_intersection(self, new_area: Area) -> None:
|
|
79
|
+
super().set_window_for_intersection(new_area)
|
|
80
|
+
|
|
81
|
+
# filter out layers we don't care about
|
|
82
|
+
self.layers = [layer for layer in self._underlying_layers if layer.area.overlaps(new_area)]
|
|
83
|
+
|
|
84
|
+
def set_window_for_union(self, new_area: Area) -> None:
|
|
85
|
+
super().set_window_for_union(new_area)
|
|
86
|
+
|
|
87
|
+
# filter out layers we don't care about
|
|
88
|
+
self.layers = [layer for layer in self._underlying_layers if layer.area.overlaps(new_area)]
|
|
89
|
+
|
|
90
|
+
def reset_window(self) -> None:
|
|
91
|
+
super().reset_window()
|
|
92
|
+
try:
|
|
93
|
+
self.layers = self._underlying_layers
|
|
94
|
+
except AttributeError:
|
|
95
|
+
pass # called from Base constructor before we've added the extra field
|
|
96
|
+
|
|
97
|
+
def read_array_with_window(self, xoffset: int, yoffset: int, xsize: int, ysize: int, window: Window) -> Any:
|
|
98
|
+
|
|
99
|
+
if (xsize <= 0) or (ysize <= 0):
|
|
100
|
+
raise ValueError("Request dimensions must be positive and non-zero")
|
|
101
|
+
|
|
102
|
+
scale = self.pixel_scale
|
|
103
|
+
assert scale is not None
|
|
104
|
+
|
|
105
|
+
target_window = Window(
|
|
106
|
+
window.xoff + xoffset,
|
|
107
|
+
window.yoff + yoffset,
|
|
108
|
+
xsize,
|
|
109
|
+
ysize
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
contributing_layers = []
|
|
113
|
+
for layer in self.layers:
|
|
114
|
+
# Normally this is hidden with set_window_for_...
|
|
115
|
+
adjusted_layer_window = Window(
|
|
116
|
+
layer.window.xoff + \
|
|
117
|
+
round_down_pixels(((layer.area.left - self._underlying_area.left) / scale.xstep), abs(scale.xstep)),
|
|
118
|
+
layer.window.yoff + \
|
|
119
|
+
round_down_pixels(((layer.area.top - self._underlying_area.top) / scale.ystep), abs(scale.ystep)),
|
|
120
|
+
layer.window.xsize,
|
|
121
|
+
layer.window.ysize,
|
|
122
|
+
)
|
|
123
|
+
intersection = Window.find_intersection_no_throw([target_window, adjusted_layer_window])
|
|
124
|
+
if intersection is not None:
|
|
125
|
+
contributing_layers.append((layer, adjusted_layer_window, intersection))
|
|
126
|
+
|
|
127
|
+
# Adding the numpy arrays over each other is relatively expensive, so if we only intersect
|
|
128
|
+
# with a single layer, turn this into a direct read
|
|
129
|
+
if len(contributing_layers) == 1:
|
|
130
|
+
layer, adjusted_layer_window, intersection = contributing_layers[0]
|
|
131
|
+
if target_window == intersection:
|
|
132
|
+
return layer.read_array(
|
|
133
|
+
intersection.xoff - adjusted_layer_window.xoff,
|
|
134
|
+
intersection.yoff - adjusted_layer_window.yoff,
|
|
135
|
+
intersection.xsize,
|
|
136
|
+
intersection.ysize
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result = np.zeros((ysize, xsize), dtype=float)
|
|
140
|
+
for layer, adjusted_layer_window, intersection in contributing_layers:
|
|
141
|
+
data = layer.read_array(
|
|
142
|
+
intersection.xoff - adjusted_layer_window.xoff,
|
|
143
|
+
intersection.yoff - adjusted_layer_window.yoff,
|
|
144
|
+
intersection.xsize,
|
|
145
|
+
intersection.ysize
|
|
146
|
+
)
|
|
147
|
+
result_x_offset = (intersection.xoff - xoffset) - window.xoff
|
|
148
|
+
result_y_offset = (intersection.yoff - yoffset) - window.yoff
|
|
149
|
+
result[
|
|
150
|
+
result_y_offset:result_y_offset + intersection.ysize,
|
|
151
|
+
result_x_offset:result_x_offset + intersection.xsize
|
|
152
|
+
] = data
|
|
153
|
+
|
|
154
|
+
return backend.promote(result)
|
|
155
|
+
|
|
156
|
+
class TileData:
|
|
157
|
+
"""This class exists just to let me sort the tiles into the correct order for processing."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, data, x, y):
|
|
160
|
+
self.data = data
|
|
161
|
+
self.x = x
|
|
162
|
+
self.y = y
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def origin(self):
|
|
166
|
+
return (self.x, self.y)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def width(self):
|
|
170
|
+
return self.data.shape[1]
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def height(self):
|
|
174
|
+
return self.data.shape[0]
|
|
175
|
+
|
|
176
|
+
def __cmp__(self, other):
|
|
177
|
+
if self.y != other.y:
|
|
178
|
+
return self.y.cmp(other.y)
|
|
179
|
+
else:
|
|
180
|
+
return self.x.cmp(other.x)
|
|
181
|
+
|
|
182
|
+
def __gt__(self, other):
|
|
183
|
+
if self.y > other.y:
|
|
184
|
+
return True
|
|
185
|
+
if self.y < other.y:
|
|
186
|
+
return False
|
|
187
|
+
return self.x.__gt__(other.x)
|
|
188
|
+
|
|
189
|
+
def __repr__(self):
|
|
190
|
+
if self.data is not None:
|
|
191
|
+
return f"<Tile: {self.x} {self.y} {self.data.shape[1]} {self.data.shape[0]}>"
|
|
192
|
+
else:
|
|
193
|
+
return "<Tile: sentinal>"
|
|
194
|
+
|
|
195
|
+
class TiledGroupLayer(GroupLayer):
|
|
196
|
+
"""An optimised version of GroupLayer for the case where you have a grid of regular sized
|
|
197
|
+
layers, e.g., map tiles.
|
|
198
|
+
|
|
199
|
+
This class exists as assembling arbitrary shaped tiles into a group is quite slow due
|
|
200
|
+
to numpy being slow to modify one array with the contents of another. This class does
|
|
201
|
+
away with that at the expense of needing to know all tiles have the same shape.__abs__()
|
|
202
|
+
|
|
203
|
+
Two notes:
|
|
204
|
+
* You can have missing tiles, and it'll fill in zeros.
|
|
205
|
+
* The tiles can overlap - e.g., JRC Annual Change tiles all overlap by a few pixels on all edges.
|
|
206
|
+
"""
|
|
207
|
+
def read_array_with_window(self, xoffset: int, yoffset: int, xsize: int, ysize: int, window: Window) -> Any:
|
|
208
|
+
if (xsize <= 0) or (ysize <= 0):
|
|
209
|
+
raise ValueError("Request dimensions must be positive and non-zero")
|
|
210
|
+
|
|
211
|
+
scale = self.pixel_scale
|
|
212
|
+
assert scale is not None
|
|
213
|
+
|
|
214
|
+
target_window = Window(
|
|
215
|
+
window.xoff + xoffset,
|
|
216
|
+
window.yoff + yoffset,
|
|
217
|
+
xsize,
|
|
218
|
+
ysize
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
partials = []
|
|
222
|
+
for layer in self.layers:
|
|
223
|
+
# Normally this is hidden with set_window_for_...
|
|
224
|
+
adjusted_layer_window = Window(
|
|
225
|
+
layer.window.xoff + \
|
|
226
|
+
round_down_pixels(((layer.area.left - self._underlying_area.left) / scale.xstep), abs(scale.xstep)),
|
|
227
|
+
layer.window.yoff + \
|
|
228
|
+
round_down_pixels(((layer.area.top - self._underlying_area.top) / scale.ystep), abs(scale.ystep)),
|
|
229
|
+
layer.window.xsize,
|
|
230
|
+
layer.window.ysize,
|
|
231
|
+
)
|
|
232
|
+
intersection = Window.find_intersection_no_throw([target_window, adjusted_layer_window])
|
|
233
|
+
if intersection is None:
|
|
234
|
+
continue
|
|
235
|
+
data = layer.read_array(
|
|
236
|
+
intersection.xoff - adjusted_layer_window.xoff,
|
|
237
|
+
intersection.yoff - adjusted_layer_window.yoff,
|
|
238
|
+
intersection.xsize,
|
|
239
|
+
intersection.ysize
|
|
240
|
+
)
|
|
241
|
+
result_x_offset = (intersection.xoff - xoffset) - window.xoff
|
|
242
|
+
result_y_offset = (intersection.yoff - yoffset) - window.yoff
|
|
243
|
+
partials.append(TileData(data, result_x_offset, result_y_offset))
|
|
244
|
+
|
|
245
|
+
sorted_partials = sorted(partials)
|
|
246
|
+
|
|
247
|
+
# When tiles overlap (hello JRC Annual Change!), then if the read aligns
|
|
248
|
+
# with the edge of a tile, then we can end up with multiple results at the same
|
|
249
|
+
# offset as we get the dregs of the above/left tile and the bulk of the data from
|
|
250
|
+
# the "obvious" tile. In which case, we should reject the smaller section. If we have
|
|
251
|
+
# two tiles at the same offset and one is not a perfect subset of the other then the
|
|
252
|
+
# tile set we were given is not regularly shaped, and so we should give up.
|
|
253
|
+
combed_partials = []
|
|
254
|
+
previous_tile = None
|
|
255
|
+
for tile in sorted_partials:
|
|
256
|
+
if previous_tile is None:
|
|
257
|
+
previous_tile = tile
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
if previous_tile.origin == tile.origin:
|
|
261
|
+
if (tile.width >= previous_tile.width) and \
|
|
262
|
+
(tile.height >= previous_tile.height):
|
|
263
|
+
previous_tile = tile
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
combed_partials.append(previous_tile)
|
|
267
|
+
previous_tile = tile
|
|
268
|
+
if previous_tile:
|
|
269
|
+
combed_partials.append(previous_tile)
|
|
270
|
+
|
|
271
|
+
# Add a terminator to force the last row to be added
|
|
272
|
+
combed_partials.append(TileData(None, 0, -1))
|
|
273
|
+
last_y_offset = None
|
|
274
|
+
last_y_height = 0
|
|
275
|
+
expected_next_x = 0
|
|
276
|
+
expected_next_y = 0
|
|
277
|
+
data = None
|
|
278
|
+
row_chunk = None
|
|
279
|
+
|
|
280
|
+
# Allow for reading off top
|
|
281
|
+
if combed_partials:
|
|
282
|
+
if combed_partials[0].y > 0:
|
|
283
|
+
row_chunk = np.zeros((combed_partials[0].y, xsize))
|
|
284
|
+
last_y_offset = 0
|
|
285
|
+
last_y_height = combed_partials[0].y
|
|
286
|
+
|
|
287
|
+
for tile in combed_partials:
|
|
288
|
+
if tile.y == last_y_offset:
|
|
289
|
+
assert row_chunk is not None
|
|
290
|
+
if row_chunk.shape[0] < tile.data.shape[0]:
|
|
291
|
+
assert last_y_height == row_chunk.shape[0]
|
|
292
|
+
row_chunk = np.vstack(
|
|
293
|
+
(row_chunk, np.zeros((tile.data.shape[0] - row_chunk.shape[0], row_chunk.shape[1])))
|
|
294
|
+
)
|
|
295
|
+
last_y_height = row_chunk.shape[0]
|
|
296
|
+
new_data = tile.data
|
|
297
|
+
if row_chunk.shape[0] != new_data.shape[0]:
|
|
298
|
+
assert row_chunk.shape[0] > new_data.shape[0]
|
|
299
|
+
# we have some overlap data from oversized tiles (hello JRC) when there's a GAP in general
|
|
300
|
+
new_data = np.vstack(
|
|
301
|
+
(new_data, np.zeros((row_chunk.shape[0] - new_data.shape[0], new_data.shape[1])))
|
|
302
|
+
)
|
|
303
|
+
assert row_chunk.shape[0] == new_data.shape[0]
|
|
304
|
+
|
|
305
|
+
# We're adding a tile to an existing row
|
|
306
|
+
x_offset = expected_next_x - tile.x
|
|
307
|
+
if x_offset == 0:
|
|
308
|
+
# Tiles line up neatly!
|
|
309
|
+
row_chunk = np.hstack((row_chunk, new_data))
|
|
310
|
+
expected_next_x = expected_next_x + new_data.shape[1]
|
|
311
|
+
elif x_offset > 0:
|
|
312
|
+
# tiles overlap
|
|
313
|
+
remainder = new_data.shape[1] - xoffset
|
|
314
|
+
if remainder > 0:
|
|
315
|
+
subdata = np.delete(new_data, np.s_[0:x_offset], 1)
|
|
316
|
+
row_chunk = np.hstack((row_chunk, subdata))
|
|
317
|
+
expected_next_x = expected_next_x + subdata.shape[1]
|
|
318
|
+
else:
|
|
319
|
+
# Gap between tiles, so fill it before adding new data
|
|
320
|
+
row_chunk = np.hstack((row_chunk, np.zeros((new_data.shape[0], -x_offset))))
|
|
321
|
+
row_chunk = np.hstack((row_chunk, new_data))
|
|
322
|
+
expected_next_x = expected_next_x + new_data.shape[1] + -x_offset
|
|
323
|
+
else:
|
|
324
|
+
# This is a new row, so we need to add the row in progress
|
|
325
|
+
# and start a new one
|
|
326
|
+
if row_chunk is not None:
|
|
327
|
+
if row_chunk.shape[1] != xsize:
|
|
328
|
+
assert row_chunk.shape[1] < xsize, f"row is too wide: expected {xsize}, is {row_chunk.shape[1]}"
|
|
329
|
+
# Missing tile at end of row, so fill in
|
|
330
|
+
row_chunk = np.hstack((row_chunk, np.zeros((last_y_height, xsize - row_chunk.shape[1]))))
|
|
331
|
+
if data is None:
|
|
332
|
+
data = row_chunk
|
|
333
|
+
expected_next_y += last_y_height
|
|
334
|
+
else:
|
|
335
|
+
if last_y_offset == expected_next_y:
|
|
336
|
+
data = np.vstack((data, row_chunk))
|
|
337
|
+
expected_next_y += last_y_height
|
|
338
|
+
else:
|
|
339
|
+
diff = expected_next_y - last_y_offset
|
|
340
|
+
assert diff > 0, f"{expected_next_y} - {last_y_offset} <= 0 (aka {diff})"
|
|
341
|
+
subdata = np.delete(row_chunk, np.s_[0:diff], 0)
|
|
342
|
+
data = np.vstack((data, subdata))
|
|
343
|
+
expected_next_y += subdata.shape[0]
|
|
344
|
+
if tile.data is not None:
|
|
345
|
+
if tile.x != 0:
|
|
346
|
+
row_chunk = np.hstack((np.zeros((tile.data.shape[0], tile.x)), tile.data))
|
|
347
|
+
else:
|
|
348
|
+
row_chunk = tile.data
|
|
349
|
+
last_y_offset = tile.y
|
|
350
|
+
last_y_height = tile.data.shape[0]
|
|
351
|
+
expected_next_x = tile.data.shape[1] + tile.x
|
|
352
|
+
|
|
353
|
+
if (last_y_offset + last_y_height) < ysize:
|
|
354
|
+
data = np.vstack((data, np.zeros((ysize - (last_y_offset + last_y_height), xsize))))
|
|
355
|
+
|
|
356
|
+
assert data.shape == (ysize, xsize)
|
|
357
|
+
return backend.promote(data)
|