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.
@@ -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)