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/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