tilearray 0.1.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.
- tilearray/__init__.py +89 -0
- tilearray/_version.py +3 -0
- tilearray/array.py +899 -0
- tilearray/decode.py +353 -0
- tilearray/errors.py +41 -0
- tilearray/fetch.py +772 -0
- tilearray/fetch_presets.py +77 -0
- tilearray/pressure.py +129 -0
- tilearray/py.typed +0 -0
- tilearray/service/__init__.py +33 -0
- tilearray/service/base.py +282 -0
- tilearray/service/config.py +455 -0
- tilearray/service/requests.py +70 -0
- tilearray/service/wcs.py +657 -0
- tilearray/service/wms.py +127 -0
- tilearray/service/wmts.py +590 -0
- tilearray/service/xyz.py +145 -0
- tilearray/tiles.py +142 -0
- tilearray/types.py +235 -0
- tilearray-0.1.0.dist-info/METADATA +345 -0
- tilearray-0.1.0.dist-info/RECORD +23 -0
- tilearray-0.1.0.dist-info/WHEEL +4 -0
- tilearray-0.1.0.dist-info/licenses/LICENSE +201 -0
tilearray/decode.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Shared tile decode pipeline with pluggable band policy and response unwrapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from email import policy
|
|
11
|
+
from email.parser import BytesParser
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from typing import Literal, cast
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from geotiff import GeoTiff # type: ignore[import-untyped]
|
|
17
|
+
from geotiff.geotiff import TiffFile # type: ignore[import-untyped]
|
|
18
|
+
from numpy.typing import NDArray
|
|
19
|
+
|
|
20
|
+
from .types import Format, TileRequest, TileResponse
|
|
21
|
+
|
|
22
|
+
try: # pragma: no cover - optional dependency
|
|
23
|
+
from PIL import Image as _PILImage
|
|
24
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
25
|
+
_PILImage = None
|
|
26
|
+
|
|
27
|
+
try: # pragma: no cover - optional dependency
|
|
28
|
+
import imageio.v2 as _imageio # type: ignore[import-not-found]
|
|
29
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
30
|
+
_imageio = None
|
|
31
|
+
|
|
32
|
+
NDArrayFloat = NDArray[np.floating]
|
|
33
|
+
BandPolicy = Literal["preserve", "first_band"]
|
|
34
|
+
ResponseUnwrapper = Callable[[bytes, TileResponse], bytes]
|
|
35
|
+
BytesReader = Callable[[bytes], NDArrayFloat]
|
|
36
|
+
TileDecoder = Callable[[TileResponse, TileRequest], NDArrayFloat]
|
|
37
|
+
|
|
38
|
+
DEFAULT_BAND_POLICIES: dict[Format, BandPolicy] = {
|
|
39
|
+
Format.GEOTIFF: "first_band",
|
|
40
|
+
Format.PNG: "preserve",
|
|
41
|
+
Format.JPEG: "preserve",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_DECODER_REGISTRY: dict[Format, TileDecoder] = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def identity_unwrapper(data: bytes, response: TileResponse) -> bytes:
|
|
48
|
+
"""Pass-through response unwrapper (default)."""
|
|
49
|
+
|
|
50
|
+
del response
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_multipart_boundary(content_type: str, data: bytes) -> str | None:
|
|
55
|
+
match = re.search(r'boundary="?([^";]+)"?', content_type, flags=re.I)
|
|
56
|
+
if match:
|
|
57
|
+
return match.group(1)
|
|
58
|
+
if data.startswith(b"--"):
|
|
59
|
+
first_line = data.split(b"\n", 1)[0]
|
|
60
|
+
if first_line.startswith(b"--") and len(first_line) > 2:
|
|
61
|
+
return first_line[2:].decode("ascii", errors="ignore").strip()
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _effective_multipart_content_type(content_type: str, data: bytes) -> str:
|
|
66
|
+
"""Use body sniffing when headers claim image/tiff but bytes are multipart."""
|
|
67
|
+
|
|
68
|
+
if "multipart/" in content_type.lower():
|
|
69
|
+
return content_type
|
|
70
|
+
boundary = _extract_multipart_boundary(content_type, data)
|
|
71
|
+
if data.startswith(b"--") and boundary:
|
|
72
|
+
return f'multipart/related; boundary="{boundary}"'
|
|
73
|
+
return content_type
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sniff_effective_content_type(content_type: str, data: bytes) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Infer the effective MIME type from bytes, not headers alone.
|
|
79
|
+
|
|
80
|
+
Used by decode and disk cache restore when ``Content-Type`` is missing,
|
|
81
|
+
generic, or misleading (e.g. ``image/tiff`` on ``multipart/related``).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
if not data:
|
|
85
|
+
return content_type
|
|
86
|
+
|
|
87
|
+
if data[:2] in (b"II", b"MM"):
|
|
88
|
+
if content_type:
|
|
89
|
+
return content_type
|
|
90
|
+
return "image/tiff"
|
|
91
|
+
|
|
92
|
+
if "multipart/" in content_type.lower() or data.startswith(b"--"):
|
|
93
|
+
return _effective_multipart_content_type(content_type, data)
|
|
94
|
+
|
|
95
|
+
return content_type
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_tiff_part(data: bytes, content_type: str) -> bytes | None:
|
|
99
|
+
effective_type = sniff_effective_content_type(content_type, data)
|
|
100
|
+
boundary = _extract_multipart_boundary(effective_type, data)
|
|
101
|
+
if not boundary:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
mime_bytes = f"Content-Type: {effective_type}\r\n\r\n".encode("ascii") + data
|
|
105
|
+
message = BytesParser(policy=policy.default).parsebytes(mime_bytes)
|
|
106
|
+
if not message.is_multipart():
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
for part in message.iter_parts():
|
|
110
|
+
part_type = (part.get_content_type() or "").lower()
|
|
111
|
+
if part_type in {"image/tiff", "image/geotiff"}:
|
|
112
|
+
payload = part.get_payload(decode=True)
|
|
113
|
+
if isinstance(payload, bytes) and payload[:2] in (b"II", b"MM"):
|
|
114
|
+
return payload
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def unwrap_multipart(data: bytes, response: TileResponse) -> bytes:
|
|
119
|
+
"""
|
|
120
|
+
Unwrap ``multipart/related`` WCS payloads (e.g. ArcGIS ImageServer).
|
|
121
|
+
|
|
122
|
+
Returns the first ``image/tiff`` part when present; otherwise passes
|
|
123
|
+
through raw bytes unchanged.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
content_type = sniff_effective_content_type(response.content_type or "", data)
|
|
127
|
+
if data[:2] in (b"II", b"MM"):
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
if "multipart/" in content_type.lower() or data.startswith(b"--"):
|
|
131
|
+
tiff_part = _extract_tiff_part(data, content_type)
|
|
132
|
+
if tiff_part is not None:
|
|
133
|
+
return tiff_part
|
|
134
|
+
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def apply_band_policy(data: NDArrayFloat, policy: BandPolicy) -> NDArrayFloat:
|
|
139
|
+
"""Reduce or preserve band dimensions according to the configured policy."""
|
|
140
|
+
|
|
141
|
+
if data.ndim <= 2 or policy == "preserve":
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
if data.ndim == 3:
|
|
145
|
+
bands_first = data.shape[0] <= 4 and data.shape[0] < min(
|
|
146
|
+
data.shape[1], data.shape[2]
|
|
147
|
+
)
|
|
148
|
+
if bands_first:
|
|
149
|
+
return cast(NDArrayFloat, data[0])
|
|
150
|
+
return cast(NDArrayFloat, data[..., 0])
|
|
151
|
+
|
|
152
|
+
return cast(NDArrayFloat, data.reshape(data.shape[0], -1)[0])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode_tile_bytes(
|
|
156
|
+
raw_bytes: bytes,
|
|
157
|
+
*,
|
|
158
|
+
reader: BytesReader,
|
|
159
|
+
band_policy: BandPolicy,
|
|
160
|
+
unwrapper: ResponseUnwrapper | None = None,
|
|
161
|
+
response: TileResponse | None = None,
|
|
162
|
+
) -> NDArrayFloat:
|
|
163
|
+
"""Run the shared decode pipeline: unwrap → read → band policy → float32."""
|
|
164
|
+
|
|
165
|
+
stub_response = response or TileResponse(
|
|
166
|
+
data=raw_bytes,
|
|
167
|
+
content_type="",
|
|
168
|
+
status_code=200,
|
|
169
|
+
headers={},
|
|
170
|
+
url="",
|
|
171
|
+
success=True,
|
|
172
|
+
)
|
|
173
|
+
effective_response = TileResponse(
|
|
174
|
+
data=stub_response.data,
|
|
175
|
+
content_type=sniff_effective_content_type(
|
|
176
|
+
stub_response.content_type, bytes(stub_response.data)
|
|
177
|
+
),
|
|
178
|
+
status_code=stub_response.status_code,
|
|
179
|
+
headers=stub_response.headers,
|
|
180
|
+
url=stub_response.url,
|
|
181
|
+
success=stub_response.success,
|
|
182
|
+
error_message=stub_response.error_message,
|
|
183
|
+
)
|
|
184
|
+
payload = (unwrapper or identity_unwrapper)(raw_bytes, effective_response)
|
|
185
|
+
data = reader(payload)
|
|
186
|
+
data = apply_band_policy(data, band_policy)
|
|
187
|
+
return cast(NDArrayFloat, np.asarray(data, dtype=np.float32))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_geotiff_bytes(raw_bytes: bytes) -> NDArrayFloat:
|
|
191
|
+
"""Read a GeoTIFF payload from bytes."""
|
|
192
|
+
|
|
193
|
+
with tempfile.NamedTemporaryFile(suffix=".tif") as tmp:
|
|
194
|
+
tmp.write(raw_bytes)
|
|
195
|
+
tmp.flush()
|
|
196
|
+
try:
|
|
197
|
+
return _read_geotiff_file(tmp.name)
|
|
198
|
+
except Exception: # pragma: no cover - fallback path
|
|
199
|
+
tif = GeoTiff(tmp.name, as_crs=None)
|
|
200
|
+
return cast(NDArrayFloat, np.asarray(tif.read(), dtype=np.float32))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _read_geotiff_file(path: str) -> NDArrayFloat:
|
|
204
|
+
tifffile_logger = logging.getLogger("tifffile")
|
|
205
|
+
previous_level = tifffile_logger.level
|
|
206
|
+
tifffile_logger.setLevel(logging.ERROR)
|
|
207
|
+
try:
|
|
208
|
+
with warnings.catch_warnings():
|
|
209
|
+
warnings.simplefilter("ignore", category=UserWarning)
|
|
210
|
+
with TiffFile(path) as tif_file:
|
|
211
|
+
data = np.asarray(tif_file.asarray(), dtype=np.float64)
|
|
212
|
+
page = tif_file.pages[0]
|
|
213
|
+
nodata_tag = page.tags.get("GDAL_NODATA")
|
|
214
|
+
if nodata_tag is not None:
|
|
215
|
+
try:
|
|
216
|
+
nodata = float(nodata_tag.value)
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
nodata = None
|
|
219
|
+
if nodata is not None and np.isfinite(nodata):
|
|
220
|
+
data[data == nodata] = np.nan
|
|
221
|
+
finally:
|
|
222
|
+
tifffile_logger.setLevel(previous_level)
|
|
223
|
+
|
|
224
|
+
invalid = ~np.isfinite(data)
|
|
225
|
+
sentinel = np.abs(data) > 1e20
|
|
226
|
+
if invalid.any() or sentinel.any():
|
|
227
|
+
data = data.copy()
|
|
228
|
+
data[invalid | sentinel] = np.nan
|
|
229
|
+
return cast(NDArrayFloat, np.asarray(data, dtype=np.float32))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def read_image_bytes(raw_bytes: bytes) -> NDArrayFloat:
|
|
233
|
+
"""Read a JPEG/PNG payload from bytes."""
|
|
234
|
+
|
|
235
|
+
if _PILImage is not None: # pragma: no cover - depends on optional library
|
|
236
|
+
with BytesIO(raw_bytes) as bio:
|
|
237
|
+
with _PILImage.open(bio) as img:
|
|
238
|
+
return cast(NDArrayFloat, np.asarray(img))
|
|
239
|
+
if _imageio is not None: # pragma: no cover
|
|
240
|
+
with BytesIO(raw_bytes) as bio:
|
|
241
|
+
return cast(NDArrayFloat, np.asarray(_imageio.imread(bio)))
|
|
242
|
+
|
|
243
|
+
msg = (
|
|
244
|
+
"PNG/JPEG decoding requires Pillow or imageio. "
|
|
245
|
+
"Install one of these packages or provide a custom tile_decoder."
|
|
246
|
+
)
|
|
247
|
+
raise RuntimeError(msg)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def make_tile_decoder(
|
|
251
|
+
reader: BytesReader,
|
|
252
|
+
*,
|
|
253
|
+
band_policy: BandPolicy,
|
|
254
|
+
unwrapper: ResponseUnwrapper | None = None,
|
|
255
|
+
) -> TileDecoder:
|
|
256
|
+
"""Build a :class:`TileDecoder` from shared pipeline pieces."""
|
|
257
|
+
|
|
258
|
+
def _decoder(response: TileResponse, request: TileRequest) -> NDArrayFloat:
|
|
259
|
+
del request
|
|
260
|
+
return decode_tile_bytes(
|
|
261
|
+
bytes(response.data),
|
|
262
|
+
reader=reader,
|
|
263
|
+
band_policy=band_policy,
|
|
264
|
+
unwrapper=unwrapper,
|
|
265
|
+
response=response,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return _decoder
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def register_tile_decoder(
|
|
272
|
+
fmt: Format,
|
|
273
|
+
decoder: TileDecoder,
|
|
274
|
+
*,
|
|
275
|
+
band_policy: BandPolicy | None = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Register a decoder for ``fmt``."""
|
|
278
|
+
|
|
279
|
+
del band_policy # reserved for future per-format policy metadata
|
|
280
|
+
_DECODER_REGISTRY[fmt] = decoder
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def decoder_for_format(fmt: Format | str | None) -> TileDecoder | None:
|
|
284
|
+
"""Return the registered decoder for ``fmt``, if any."""
|
|
285
|
+
|
|
286
|
+
if isinstance(fmt, Format):
|
|
287
|
+
return _DECODER_REGISTRY.get(fmt)
|
|
288
|
+
if isinstance(fmt, str):
|
|
289
|
+
try:
|
|
290
|
+
fmt_enum = Format(fmt)
|
|
291
|
+
except ValueError:
|
|
292
|
+
return None
|
|
293
|
+
return _DECODER_REGISTRY.get(fmt_enum)
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def band_count_from_array(array: NDArrayFloat) -> int:
|
|
298
|
+
"""Return the number of bands represented by a decoded tile array."""
|
|
299
|
+
|
|
300
|
+
if array.ndim == 3:
|
|
301
|
+
bands_first = array.shape[0] <= 4 and array.shape[0] < min(
|
|
302
|
+
array.shape[1], array.shape[2]
|
|
303
|
+
)
|
|
304
|
+
if bands_first:
|
|
305
|
+
return int(array.shape[0])
|
|
306
|
+
return int(array.shape[2])
|
|
307
|
+
return 1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def default_band_count_for_format(fmt: Format | None) -> int | None:
|
|
311
|
+
"""
|
|
312
|
+
Return a fixed band count when known, or ``None`` when callers must probe.
|
|
313
|
+
|
|
314
|
+
GeoTIFF elevation tiles are always reduced to a single band by policy.
|
|
315
|
+
JPEG/PNG band count depends on the source imagery.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
if fmt is None:
|
|
319
|
+
return None
|
|
320
|
+
policy = DEFAULT_BAND_POLICIES.get(fmt)
|
|
321
|
+
if policy == "first_band":
|
|
322
|
+
return 1
|
|
323
|
+
if policy == "preserve":
|
|
324
|
+
return None
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def read_geotiff_path(path: str) -> NDArrayFloat:
|
|
329
|
+
"""Read a GeoTIFF from disk and apply the default elevation band policy."""
|
|
330
|
+
|
|
331
|
+
data = _read_geotiff_file(path)
|
|
332
|
+
return apply_band_policy(data, DEFAULT_BAND_POLICIES[Format.GEOTIFF])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _register_default_decoders() -> None:
|
|
336
|
+
register_tile_decoder(
|
|
337
|
+
Format.GEOTIFF,
|
|
338
|
+
make_tile_decoder(
|
|
339
|
+
read_geotiff_bytes,
|
|
340
|
+
band_policy=DEFAULT_BAND_POLICIES[Format.GEOTIFF],
|
|
341
|
+
unwrapper=unwrap_multipart,
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
if _PILImage is not None or _imageio is not None: # pragma: no cover
|
|
345
|
+
image_decoder = make_tile_decoder(
|
|
346
|
+
read_image_bytes,
|
|
347
|
+
band_policy=DEFAULT_BAND_POLICIES[Format.PNG],
|
|
348
|
+
)
|
|
349
|
+
register_tile_decoder(Format.PNG, image_decoder)
|
|
350
|
+
register_tile_decoder(Format.JPEG, image_decoder)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
_register_default_decoders()
|
tilearray/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Custom exception hierarchy for TileArray."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TileArrayError(Exception):
|
|
7
|
+
"""Base exception for TileArray library."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, cause: Optional[Exception] = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.cause = cause
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServiceError(TileArrayError):
|
|
15
|
+
"""Errors related to geospatial service interactions."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(TileArrayError):
|
|
21
|
+
"""Data validation errors."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NetworkError(TileArrayError):
|
|
27
|
+
"""Network-related errors."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParseError(TileArrayError):
|
|
33
|
+
"""Data parsing errors."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigurationError(TileArrayError):
|
|
39
|
+
"""Configuration and setup errors."""
|
|
40
|
+
|
|
41
|
+
pass
|