coglite 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.
coglite/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """coglite: read Cloud-Optimized GeoTIFFs over HTTP(S) or from local files without GDAL.
2
+
3
+ >>> import coglite
4
+ >>> cog = coglite.open("https://example.com/data/cog.tif") # doctest: +SKIP
5
+ >>> cog.profile, cog.bounds, cog.crs_epsg # doctest: +SKIP
6
+ >>> arr = cog.read(window=(0, 0, 512, 512), bands=[1, 2, 3]) # doctest: +SKIP
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .codecs import available_codecs
14
+ from .cog import COG, Level, RasterResult, Window, open
15
+ from .errors import (
16
+ CodecError,
17
+ COGLiteError,
18
+ CRSError,
19
+ RangeRequestError,
20
+ TIFFError,
21
+ UnsupportedError,
22
+ )
23
+ from .geo import Affine
24
+ from .io import (
25
+ BytesReader,
26
+ CallableReader,
27
+ FileReader,
28
+ HTTPReader,
29
+ RangeReader,
30
+ ReaderStats,
31
+ register_scheme,
32
+ )
33
+ from .validate import ValidationReport
34
+
35
+ __all__ = [
36
+ "__version__",
37
+ "open",
38
+ "COG",
39
+ "Window",
40
+ "Level",
41
+ "RasterResult",
42
+ "Affine",
43
+ "ValidationReport",
44
+ "RangeReader",
45
+ "HTTPReader",
46
+ "FileReader",
47
+ "BytesReader",
48
+ "CallableReader",
49
+ "ReaderStats",
50
+ "register_scheme",
51
+ "available_codecs",
52
+ "COGLiteError",
53
+ "TIFFError",
54
+ "UnsupportedError",
55
+ "CodecError",
56
+ "RangeRequestError",
57
+ "CRSError",
58
+ ]
coglite/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ """Command line interface: ``coglite info | read | validate``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any, List, Optional, Sequence
9
+
10
+ import numpy as np
11
+
12
+ from . import __version__
13
+ from .cog import COG, Window, open
14
+ from .errors import COGLiteError
15
+ from .png import write_png
16
+
17
+
18
+ def _open(args: argparse.Namespace) -> COG:
19
+ headers = {}
20
+ for h in args.header or []:
21
+ if ":" not in h:
22
+ raise SystemExit(f"--header expects 'Name: value', got {h!r}")
23
+ k, v = h.split(":", 1)
24
+ headers[k.strip()] = v.strip()
25
+ return open(args.source, headers=headers or None, timeout=args.timeout)
26
+
27
+
28
+ def _bands(text: Optional[str]) -> Optional[List[int]]:
29
+ if not text:
30
+ return None
31
+ return [int(b) for b in text.split(",") if b.strip()]
32
+
33
+
34
+ def _json_default(o: Any) -> Any:
35
+ if isinstance(o, (np.integer,)):
36
+ return int(o)
37
+ if isinstance(o, (np.floating,)):
38
+ return float(o)
39
+ return str(o)
40
+
41
+
42
+ def cmd_info(args: argparse.Namespace) -> int:
43
+ with _open(args) as cog:
44
+ info = cog.info()
45
+ if args.json:
46
+ print(json.dumps(info, indent=2, default=_json_default))
47
+ return 0
48
+ print(f"Source: {info['source']}")
49
+ print(
50
+ f"Size: {info['width']} x {info['height']} pixels, {info['count']} band(s), {info['dtype']}"
51
+ )
52
+ print(f"CRS: EPSG:{info['crs_epsg']}" if info["crs_epsg"] else "CRS: unknown")
53
+ print(f"Transform: {info['transform']}")
54
+ print(f"Bounds: {info['bounds']}")
55
+ nod = "nan" if info["nodata_is_nan"] else info["nodata"]
56
+ print(f"NoData: {nod}")
57
+ print(
58
+ f"Layout: {'tiled' if info['tiled'] else 'stripped'} {info['blockxsize']}x{info['blockysize']}, "
59
+ f"{info['compress']}, predictor {info['predictor']}, interleave {info['interleave']}"
60
+ )
61
+ print(f"Format: {'BigTIFF' if info['bigtiff'] else 'TIFF'}, {info['byteorder']}-endian")
62
+ print(f"Overviews: {', '.join(f'{w}x{h}' for w, h in info['overviews']) or 'none'}")
63
+ print(f"Mask: {'internal' if info['has_mask'] else 'none'}")
64
+ if any(s != 1.0 for s in info["scales"]) or any(o != 0.0 for o in info["offsets"]):
65
+ print(f"Scale/offset: {info['scales']} / {info['offsets']}")
66
+ st = cog.reader.stats
67
+ print(f"I/O: {st.requests} request(s), {st.bytes_fetched} bytes")
68
+ return 0
69
+
70
+
71
+ def cmd_read(args: argparse.Namespace) -> int:
72
+ out = args.output
73
+ if not out.lower().endswith((".npy", ".png")):
74
+ raise SystemExit("--output must end with .npy or .png")
75
+ with _open(args) as cog:
76
+ bands = _bands(args.bands)
77
+ if args.bbox:
78
+ res = cog.read_bbox(args.bbox, crs=args.crs, max_size=args.max_size, bands=bands, masked=True)
79
+ data = res.data
80
+ msg = f"level {res.level}, window {tuple(res.window)}, transform {tuple(res.transform) if res.transform else None}"
81
+ else:
82
+ win = Window(*args.window) if args.window else None
83
+ data = cog.read(win, bands, args.overview, masked=True)
84
+ msg = f"window {tuple(win) if win else 'full'}, overview {args.overview}"
85
+ if out.lower().endswith(".npy"):
86
+ np.save(out, np.ma.getdata(data))
87
+ else:
88
+ write_png(out, data)
89
+ st = cog.reader.stats
90
+ print(
91
+ f"wrote {out}: shape {tuple(data.shape)} {data.dtype}; {msg}; "
92
+ f"{st.requests} request(s), {st.bytes_fetched} bytes"
93
+ )
94
+ return 0
95
+
96
+
97
+ def cmd_validate(args: argparse.Namespace) -> int:
98
+ with _open(args) as cog:
99
+ report = cog.validate()
100
+ if args.json:
101
+ print(json.dumps(report.to_dict(), indent=2, default=_json_default))
102
+ else:
103
+ print(report)
104
+ return 0 if report.is_valid else 1
105
+
106
+
107
+ def build_parser() -> argparse.ArgumentParser:
108
+ p = argparse.ArgumentParser(prog="coglite", description="Read Cloud-Optimized GeoTIFFs without GDAL.")
109
+ p.add_argument("--version", action="version", version=f"coglite {__version__}")
110
+ sub = p.add_subparsers(dest="command", required=True)
111
+
112
+ def common(sp: argparse.ArgumentParser) -> None:
113
+ sp.add_argument("source", help="local path or http(s):// URL")
114
+ sp.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds")
115
+ sp.add_argument("--header", action="append", help="extra HTTP header 'Name: value' (repeatable)")
116
+
117
+ sp = sub.add_parser("info", help="print the profile of a COG")
118
+ common(sp)
119
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
120
+ sp.set_defaults(func=cmd_info)
121
+
122
+ sp = sub.add_parser("read", help="read a bbox or window to .npy or .png")
123
+ common(sp)
124
+ g = sp.add_mutually_exclusive_group()
125
+ g.add_argument("--bbox", type=float, nargs=4, metavar=("LEFT", "BOTTOM", "RIGHT", "TOP"))
126
+ g.add_argument("--window", type=int, nargs=4, metavar=("COL", "ROW", "WIDTH", "HEIGHT"))
127
+ sp.add_argument("--crs", default=None, help="CRS of --bbox (default: raster CRS); EPSG:4326/3857 only")
128
+ sp.add_argument("--max-size", type=int, default=1024, help="longest output side for --bbox")
129
+ sp.add_argument("--overview", type=int, default=None, help="overview index for --window")
130
+ sp.add_argument("--bands", default=None, help="comma separated 1-based band list")
131
+ sp.add_argument("-o", "--output", required=True, help="output .npy or .png file")
132
+ sp.set_defaults(func=cmd_read)
133
+
134
+ sp = sub.add_parser("validate", help="check Cloud-Optimized GeoTIFF layout rules")
135
+ common(sp)
136
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
137
+ sp.set_defaults(func=cmd_validate)
138
+ return p
139
+
140
+
141
+ def main(argv: Optional[Sequence[str]] = None) -> int:
142
+ parser = build_parser()
143
+ args = parser.parse_args(argv)
144
+ try:
145
+ return int(args.func(args))
146
+ except (COGLiteError, OSError, ValueError, IndexError) as exc:
147
+ print(f"coglite: error: {exc}", file=sys.stderr)
148
+ return 2
149
+
150
+
151
+ if __name__ == "__main__": # pragma: no cover
152
+ sys.exit(main())
coglite/codecs.py ADDED
@@ -0,0 +1,290 @@
1
+ """Decompression codecs and TIFF predictors.
2
+
3
+ Built in (standard library + numpy only):
4
+
5
+ * ``1`` no compression
6
+ * ``5`` LZW (pure-Python decoder, TIFF "early change" variant)
7
+ * ``8`` Deflate / zlib (also the obsolete code ``32946``)
8
+ * ``32773`` PackBits
9
+ * ``34925`` LZMA
10
+
11
+ Optional, through the ``imagecodecs`` package (``pip install coglite[imagecodecs]``):
12
+
13
+ * ``7`` JPEG
14
+ * ``50000`` ZSTD (also served by the standard library on Python 3.14+)
15
+ * ``50001`` WebP
16
+ * ``34887`` LERC (optionally wrapped in Deflate or ZSTD)
17
+
18
+ Predictors: ``1`` none, ``2`` horizontal differencing (integers) and ``3``
19
+ floating-point (byte-shuffled differencing).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import zlib
25
+ from typing import Any, Callable, Dict, Optional, Sequence, Union
26
+
27
+ import numpy as np
28
+
29
+ from .errors import CodecError, TIFFError
30
+
31
+ __all__ = [
32
+ "COMPRESSION_NAMES",
33
+ "lzw_decode",
34
+ "packbits_decode",
35
+ "decompress",
36
+ "is_image_codec",
37
+ "undo_horizontal_predictor",
38
+ "undo_float_predictor",
39
+ "available_codecs",
40
+ ]
41
+
42
+ COMPRESSION_NAMES: Dict[int, str] = {
43
+ 1: "none",
44
+ 5: "lzw",
45
+ 6: "ojpeg",
46
+ 7: "jpeg",
47
+ 8: "deflate",
48
+ 32946: "deflate",
49
+ 32773: "packbits",
50
+ 34887: "lerc",
51
+ 34925: "lzma",
52
+ 50000: "zstd",
53
+ 50001: "webp",
54
+ }
55
+
56
+ #: Codecs that return an image array instead of a byte stream.
57
+ _IMAGE_CODECS = {7, 50001, 34887}
58
+
59
+
60
+ def is_image_codec(compression: int) -> bool:
61
+ """Return True if the codec decodes straight to pixels (predictors do not apply)."""
62
+ return compression in _IMAGE_CODECS
63
+
64
+
65
+ def _imagecodecs() -> Any:
66
+ try:
67
+ import imagecodecs # type: ignore[import-not-found,import-untyped,unused-ignore]
68
+ except ImportError: # pragma: no cover - depends on the environment
69
+ return None
70
+ return imagecodecs
71
+
72
+
73
+ def available_codecs() -> Dict[str, bool]:
74
+ """Report which compression schemes can be decoded in this environment."""
75
+ ic = _imagecodecs()
76
+ zstd_ok = _zstd_decoder() is not None
77
+ return {
78
+ "none": True,
79
+ "lzw": True,
80
+ "deflate": True,
81
+ "packbits": True,
82
+ "lzma": True,
83
+ "zstd": zstd_ok,
84
+ "jpeg": ic is not None,
85
+ "webp": ic is not None,
86
+ "lerc": ic is not None,
87
+ }
88
+
89
+
90
+ # --------------------------------------------------------------------------- LZW
91
+
92
+
93
+ def lzw_decode(data: bytes) -> bytes:
94
+ """Decode TIFF LZW data (MSB-first codes, 9-12 bits, early change).
95
+
96
+ Raises :class:`~coglite.errors.TIFFError` for corrupt streams and for the
97
+ obsolete pre-TIFF-6 LSB-first variant.
98
+ """
99
+ if len(data) >= 2 and data[0] == 0 and (data[1] & 0x01):
100
+ raise CodecError("old-style (LSB-first) LZW is not supported")
101
+ buf = bytes(data) + b"\x00\x00\x00"
102
+ nbits = len(data) * 8
103
+ out = bytearray()
104
+ table: list = [bytes((i,)) for i in range(256)]
105
+ table.append(b"") # 256 clear
106
+ table.append(b"") # 257 end of information
107
+ append = table.append
108
+ bitpos = 0
109
+ code_len = 9
110
+ mask = 0x1FF
111
+ prev: Optional[bytes] = None
112
+ while bitpos + code_len <= nbits:
113
+ p = bitpos >> 3
114
+ word = (buf[p] << 16) | (buf[p + 1] << 8) | buf[p + 2]
115
+ code = (word >> (24 - (bitpos & 7) - code_len)) & mask
116
+ bitpos += code_len
117
+ if code == 257:
118
+ break
119
+ if code == 256:
120
+ del table[258:]
121
+ code_len = 9
122
+ mask = 0x1FF
123
+ prev = None
124
+ continue
125
+ if prev is None:
126
+ if code > 255:
127
+ raise TIFFError("corrupt LZW stream: first code after clear is not a literal")
128
+ entry = table[code]
129
+ out += entry
130
+ prev = entry
131
+ continue
132
+ n = len(table)
133
+ if code < n:
134
+ entry = table[code]
135
+ append(prev + entry[:1])
136
+ elif code == n:
137
+ entry = prev + prev[:1]
138
+ append(entry)
139
+ else:
140
+ raise TIFFError(f"corrupt LZW stream: code {code} > table size {n}")
141
+ out += entry
142
+ prev = entry
143
+ if len(table) >= (1 << code_len) - 1 and code_len < 12:
144
+ code_len += 1
145
+ mask = (1 << code_len) - 1
146
+ return bytes(out)
147
+
148
+
149
+ # ---------------------------------------------------------------------- PackBits
150
+
151
+
152
+ def packbits_decode(data: bytes) -> bytes:
153
+ """Decode Apple PackBits run-length encoded data."""
154
+ out = bytearray()
155
+ i = 0
156
+ n = len(data)
157
+ while i < n:
158
+ h = data[i]
159
+ i += 1
160
+ if h < 128:
161
+ out += data[i : i + h + 1]
162
+ i += h + 1
163
+ elif h > 128:
164
+ if i < n:
165
+ out += bytes((data[i],)) * (257 - h)
166
+ i += 1
167
+ # h == 128 is a no-op
168
+ return bytes(out)
169
+
170
+
171
+ # ---------------------------------------------------------------------- dispatch
172
+
173
+
174
+ def _zstd_decoder() -> Optional[Callable[[bytes], bytes]]:
175
+ try: # Python 3.14+
176
+ from compression import zstd
177
+ except ImportError:
178
+ pass
179
+ else:
180
+ decoder: Callable[[bytes], bytes] = zstd.decompress
181
+ return decoder
182
+ ic = _imagecodecs()
183
+ if ic is not None:
184
+ return ic.zstd_decode # type: ignore[no-any-return,unused-ignore]
185
+ return None
186
+
187
+
188
+ def _need_imagecodecs(name: str) -> Any:
189
+ ic = _imagecodecs()
190
+ if ic is None:
191
+ raise CodecError(
192
+ f"{name} compression needs the optional 'imagecodecs' package: pip install 'coglite[imagecodecs]'"
193
+ )
194
+ return ic
195
+
196
+
197
+ def _jpeg_colorspaces(photometric: int, planar: int, extrasamples: Sequence[int]) -> tuple:
198
+ if photometric == 6: # YCbCr stored, return RGB
199
+ return 3, 2
200
+ if photometric == 2 and planar == 1 and not extrasamples:
201
+ return 2, 2 # RGB stored as RGB, no conversion
202
+ return None, None
203
+
204
+
205
+ def decompress(
206
+ compression: int,
207
+ data: bytes,
208
+ *,
209
+ jpeg_tables: Optional[bytes] = None,
210
+ photometric: int = 1,
211
+ planar: int = 1,
212
+ extrasamples: Sequence[int] = (),
213
+ lerc_parameters: Sequence[int] = (),
214
+ ) -> Union[bytes, np.ndarray]:
215
+ """Decompress one tile or strip.
216
+
217
+ Byte-stream codecs return ``bytes``; image codecs (JPEG, WebP, LERC)
218
+ return a numpy array.
219
+ """
220
+ if compression == 1:
221
+ return bytes(data)
222
+ if compression in (8, 32946):
223
+ try:
224
+ return zlib.decompress(data)
225
+ except zlib.error:
226
+ # Some writers emit truncated/raw streams; decompressobj is lenient.
227
+ try:
228
+ return zlib.decompressobj().decompress(data)
229
+ except zlib.error as exc:
230
+ raise TIFFError(f"corrupt Deflate data: {exc}") from exc
231
+ if compression == 5:
232
+ return lzw_decode(data)
233
+ if compression == 32773:
234
+ return packbits_decode(data)
235
+ if compression == 34925:
236
+ import lzma
237
+
238
+ return lzma.decompress(data)
239
+ if compression == 50000:
240
+ dec = _zstd_decoder()
241
+ if dec is None:
242
+ _need_imagecodecs("ZSTD")
243
+ assert dec is not None
244
+ return bytes(dec(data))
245
+ if compression == 7:
246
+ ic = _need_imagecodecs("JPEG")
247
+ cs, ocs = _jpeg_colorspaces(photometric, planar, extrasamples)
248
+ return np.asarray(ic.jpeg_decode(data, tables=jpeg_tables, colorspace=cs, outcolorspace=ocs))
249
+ if compression == 50001:
250
+ ic = _need_imagecodecs("WebP")
251
+ return np.asarray(ic.webp_decode(data))
252
+ if compression == 34887:
253
+ ic = _need_imagecodecs("LERC")
254
+ extra = lerc_parameters[1] if len(lerc_parameters) > 1 else 0
255
+ if extra == 1:
256
+ data = zlib.decompress(data)
257
+ elif extra == 2:
258
+ data = ic.zstd_decode(data)
259
+ return np.asarray(ic.lerc_decode(data))
260
+ name = COMPRESSION_NAMES.get(compression, "unknown")
261
+ raise CodecError(f"compression {compression} ({name}) is not supported")
262
+
263
+
264
+ # -------------------------------------------------------------------- predictors
265
+
266
+
267
+ def undo_horizontal_predictor(arr: np.ndarray) -> np.ndarray:
268
+ """Undo predictor 2 on an array shaped ``(rows, cols, samples)`` (native byte order)."""
269
+ if arr.dtype.kind == "f":
270
+ # libtiff differences the raw bit patterns as unsigned integers.
271
+ as_int = np.ascontiguousarray(arr).view(f"u{arr.dtype.itemsize}")
272
+ return np.cumsum(as_int, axis=1, dtype=as_int.dtype).view(arr.dtype)
273
+ return np.cumsum(arr, axis=1, dtype=arr.dtype)
274
+
275
+
276
+ def undo_float_predictor(raw: bytes, rows: int, cols: int, samples: int, itemsize: int) -> np.ndarray:
277
+ """Undo predictor 3 and return float values shaped ``(rows, cols, samples)``.
278
+
279
+ The floating-point predictor first splits every row into byte planes (most
280
+ significant byte first) and then applies byte-wise horizontal differencing
281
+ with a stride of ``samples``; this function reverses both steps.
282
+ """
283
+ nbytes = rows * cols * samples * itemsize
284
+ buf = np.frombuffer(raw, dtype=np.uint8, count=nbytes)
285
+ b = buf.reshape(rows, cols * itemsize, samples)
286
+ b = np.cumsum(b, axis=1, dtype=np.uint8)
287
+ planes = b.reshape(rows, itemsize, cols * samples)
288
+ shuffled = np.ascontiguousarray(planes.transpose(0, 2, 1))
289
+ values = shuffled.view(f">f{itemsize}").reshape(rows, cols, samples)
290
+ return values.astype(values.dtype.newbyteorder("="))