rumi-eo 0.8.3__tar.gz

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,73 @@
1
+ # python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .tox/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ .hypothesis/
14
+
15
+ # c++ / cmake
16
+ build/
17
+ build-*/
18
+ cmake-build-*/
19
+ *.o
20
+ *.obj
21
+ *.so
22
+ *.so.*
23
+ *.dylib
24
+ *.dll
25
+ *.a
26
+ *.lib
27
+ *.pdb
28
+ *.exp
29
+ CMakeCache.txt
30
+ CMakeFiles/
31
+ CMakeUserPresets.json
32
+ cmake_install.cmake
33
+ *.ninja
34
+ .ninja_deps
35
+ .ninja_log
36
+ compile_commands.json
37
+ .cache/
38
+
39
+ # virtualenvs
40
+ .venv/
41
+ venv/
42
+ env/
43
+
44
+ # distribution
45
+ dist/
46
+ wheelhouse/
47
+ *.whl
48
+
49
+ # editors
50
+ .vscode/
51
+ .idea/
52
+ *.swp
53
+ *.swo
54
+ *~
55
+
56
+ # os
57
+ .DS_Store
58
+ Thumbs.db
59
+ desktop.ini
60
+
61
+ # misc
62
+ *.log
63
+
64
+
65
+ # test data
66
+ core/tests/fixtures/data/
67
+ demo.py
68
+
69
+ T30TYK_B04_shortcog.tif
70
+
71
+ demo_shortcog.tif
72
+
73
+ demo.tif
rumi_eo-0.8.3/PKG-INFO ADDED
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: rumi-eo
3
+ Version: 0.8.3
4
+ Summary: A GeoTIFF profile for AI training data.
5
+ Project-URL: Homepage, https://github.com/asterisk-labs/rumi
6
+ Project-URL: Repository, https://github.com/asterisk-labs/rumi
7
+ Project-URL: Issues, https://github.com/asterisk-labs/rumi/issues
8
+ Author-email: Cesar Aybar <cesar@asterisk.coop>
9
+ License: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: C++
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: certifi
25
+ Requires-Dist: cffi>=1.17
26
+ Requires-Dist: numpy>=1.24
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # shortcog (python)
32
+
33
+ Python bindings for the shortcog reader. See the top level `README.md` for the public API and usage examples.
@@ -0,0 +1,3 @@
1
+ # shortcog (python)
2
+
3
+ Python bindings for the shortcog reader. See the top level `README.md` for the public API and usage examples.
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+
3
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
4
+
5
+ _LIB_GLOBS = ("*.so", "*.so.*", "*.dylib", "*.dll")
6
+
7
+
8
+ class CustomBuildHook(BuildHookInterface):
9
+ def initialize(self, version, build_data):
10
+ lib_dir = Path(self.root) / "rumi" / "_lib"
11
+ found = [p for g in _LIB_GLOBS for p in lib_dir.glob(g)]
12
+ if not found:
13
+ raise RuntimeError(
14
+ f"no native library in {lib_dir}. Build it first: "
15
+ "`make lib` from the repo root."
16
+ )
17
+
18
+ # Binary inside, so tag the wheel for this platform, not py3-none-any.
19
+ build_data["pure_python"] = False
20
+ build_data["infer_tag"] = True
@@ -0,0 +1,91 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "rumi-eo"
8
+ dynamic = ["version"]
9
+ description = "A GeoTIFF profile for AI training data."
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+ requires-python = ">=3.11"
13
+
14
+ authors = [
15
+ { name = "Cesar Aybar", email = "cesar@asterisk.coop" },
16
+ ]
17
+
18
+ dependencies = [
19
+ "numpy>=1.24",
20
+ "cffi>=1.17",
21
+ "certifi",
22
+ ]
23
+
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: Science/Research",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: MacOS",
30
+ "Operating System :: POSIX :: Linux",
31
+ "Programming Language :: C++",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Topic :: Scientific/Engineering :: GIS",
37
+ "Typing :: Typed",
38
+ ]
39
+
40
+ [project.optional-dependencies]
41
+ test = [
42
+ "pytest>=7",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/asterisk-labs/rumi"
47
+ Repository = "https://github.com/asterisk-labs/rumi"
48
+ Issues = "https://github.com/asterisk-labs/rumi/issues"
49
+
50
+
51
+ [tool.hatch.version]
52
+ path = "../../VERSION"
53
+ pattern = "(?P<version>\\S+)"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["rumi"]
57
+ artifacts = [
58
+ "rumi/_lib/*.so",
59
+ "rumi/_lib/*.so.*",
60
+ "rumi/_lib/*.dylib",
61
+ "rumi/_lib/*.dll",
62
+ ]
63
+
64
+ [tool.hatch.build.targets.wheel.hooks.custom]
65
+ path = "hatch_build.py"
66
+
67
+ [tool.hatch.build.targets.sdist]
68
+ include = [
69
+ "rumi/**",
70
+ "tests/**",
71
+ "hatch_build.py",
72
+ "README.md",
73
+ ]
74
+ exclude = [
75
+ "rumi/_lib/*.so",
76
+ "rumi/_lib/*.so.*",
77
+ "rumi/_lib/*.dylib",
78
+ "rumi/_lib/*.dll",
79
+ ]
80
+
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ addopts = "-ra --strict-markers"
85
+
86
+ [tool.ruff]
87
+ line-length = 100
88
+ target-version = "py311"
89
+
90
+ [tool.ruff.lint]
91
+ select = ["E", "F", "I", "B", "UP", "NPY"]
@@ -0,0 +1,16 @@
1
+ from importlib.metadata import version
2
+
3
+ from ._ffi import lib
4
+ from ._read import index_file, parse, read
5
+ from ._spec import Spec
6
+ from ._write import Layout, tile, write
7
+
8
+ __version__ = version("rumi-eo")
9
+
10
+ # The OpenZL frame format version.
11
+ OPENZL_VERSION = lib.rumi_openzl_format_version()
12
+
13
+ __all__ = [
14
+ "Layout", "OPENZL_VERSION", "Spec", "__version__",
15
+ "index_file", "parse", "read", "tile", "write",
16
+ ]
@@ -0,0 +1,38 @@
1
+ import numpy as np
2
+
3
+ # (sample_format, bits_per_sample) is the canonical sample encoding from the spec.
4
+ _ENCODING: dict[type[np.generic], tuple[int, int]] = {
5
+ np.uint8: (1, 8),
6
+ np.uint16: (1, 16),
7
+ np.uint32: (1, 32),
8
+ np.uint64: (1, 64),
9
+ np.int8: (2, 8),
10
+ np.int16: (2, 16),
11
+ np.int32: (2, 32),
12
+ np.int64: (2, 64),
13
+ np.float16: (3, 16),
14
+ np.float32: (3, 32),
15
+ np.float64: (3, 64),
16
+ np.complex64: (6, 64),
17
+ np.complex128: (6, 128),
18
+ }
19
+
20
+ _DECODE: dict[tuple[int, int], type[np.generic]] = {
21
+ enc: dt for dt, enc in _ENCODING.items()
22
+ }
23
+
24
+
25
+ def sample_encoding(dtype) -> tuple[int, int]:
26
+ try:
27
+ return _ENCODING[np.dtype(dtype).type]
28
+ except KeyError:
29
+ raise ValueError(f"unsupported dtype {np.dtype(dtype)}") from None
30
+
31
+
32
+ def np_dtype(sf: int, bps: int) -> type[np.generic]:
33
+ try:
34
+ return _DECODE[(sf, bps)]
35
+ except KeyError:
36
+ raise NotImplementedError(
37
+ f"no numpy dtype for (sample_format={sf}, bits_per_sample={bps})"
38
+ ) from None
@@ -0,0 +1,173 @@
1
+ import ctypes.util
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from cffi import FFI
6
+
7
+ _CDEF = """
8
+ typedef enum {
9
+ RUMI_OK = 0,
10
+ RUMI_ERR_INVALID = 1,
11
+ RUMI_ERR_IO = 2,
12
+ RUMI_ERR_PARSE = 3,
13
+ RUMI_ERR_FORMAT = 4,
14
+ RUMI_ERR_DECODE = 5,
15
+ RUMI_ERR_OOM = 6,
16
+ RUMI_ERR_UNSUPPORTED = 7,
17
+ RUMI_ERR_INTERNAL = 99
18
+ } rumi_status;
19
+
20
+ typedef struct {
21
+ uint32_t image_width;
22
+ uint32_t image_length;
23
+ uint16_t tile_width;
24
+ uint16_t tile_length;
25
+ uint16_t samples_per_pixel;
26
+ uint8_t bits_per_sample;
27
+ uint8_t sample_format;
28
+ uint32_t tiles_across;
29
+ uint32_t tiles_down;
30
+ uint64_t base_tiles_offset;
31
+ } rumi_header;
32
+
33
+ typedef struct {
34
+ int64_t shape[4];
35
+ int ndim;
36
+ int64_t sn;
37
+ int64_t sb;
38
+ int64_t sy;
39
+ int64_t sx;
40
+ int native;
41
+ } rumi_layout;
42
+
43
+ typedef struct rumi_spec rumi_spec;
44
+
45
+ int rumi_api_version(void);
46
+ const char* rumi_version_string(void);
47
+ int rumi_openzl_format_version(void);
48
+ const char* rumi_last_error(void);
49
+ void rumi_clear_error(void);
50
+ void rumi_free(void* ptr);
51
+
52
+ rumi_status
53
+ rumi_index_file(const char* path, unsigned char** out_blob, size_t* out_size);
54
+
55
+ rumi_status
56
+ rumi_compile_layout(const char* pattern,
57
+ int64_t n, int64_t b, int64_t y, int64_t x,
58
+ rumi_layout* out);
59
+
60
+ rumi_status
61
+ rumi_spec_parse(const unsigned char* blob, size_t blob_size,
62
+ rumi_spec** out);
63
+
64
+ void rumi_spec_destroy(rumi_spec* spec);
65
+
66
+ rumi_status
67
+ rumi_spec_header(const rumi_spec* spec, rumi_header* out);
68
+
69
+ rumi_status
70
+ rumi_read(const char* path, const rumi_spec* spec,
71
+ const int* bands, size_t n_bands,
72
+ int y_off, int y_size, int x_off, int x_size,
73
+ const char* pattern, int num_threads,
74
+ void* dst, size_t dst_size);
75
+
76
+ rumi_status
77
+ rumi_read_stack(const char* const* paths,
78
+ const rumi_spec* const* specs, size_t n_images,
79
+ const int* n_index, size_t n_n,
80
+ const int* bands, size_t n_bands,
81
+ int y_off, int y_size, int x_off, int x_size,
82
+ const char* pattern, int num_threads,
83
+ void* dst, size_t dst_size);
84
+
85
+ rumi_status
86
+ rumi_geokeys(const char* srs, int pixel_is_point,
87
+ unsigned char** out_dir, size_t* out_dir_size,
88
+ unsigned char** out_dbl, size_t* out_dbl_size,
89
+ unsigned char** out_ascii, size_t* out_ascii_size);
90
+ """
91
+
92
+
93
+ ffi = FFI()
94
+ ffi.cdef(_CDEF)
95
+
96
+ _LIB_GLOBS = ("*.so", "*.so.*", "*.dylib", "*.dll")
97
+
98
+
99
+ def _ensure_ca_bundle():
100
+ # Bundled libcurl/openssl come from conda and look for CA certs at a conda
101
+ # path absent off-conda (Colab, venvs), breaking HTTPS /vsicurl/ reads.
102
+ if any(os.environ.get(v) for v in (
103
+ "CURL_CA_BUNDLE", "GDAL_CURL_CA_BUNDLE", "SSL_CERT_FILE",
104
+ "GDAL_HTTP_UNSAFESSL",
105
+ )):
106
+ return
107
+
108
+ bundle = None
109
+ try:
110
+ import certifi
111
+ bundle = certifi.where()
112
+ except Exception:
113
+ for p in ("/etc/ssl/certs/ca-certificates.crt",
114
+ "/etc/pki/tls/certs/ca-bundle.crt",
115
+ "/etc/ssl/cert.pem"):
116
+ if os.path.exists(p):
117
+ bundle = p
118
+ break
119
+
120
+ if bundle and os.path.exists(bundle):
121
+ os.environ.setdefault("CURL_CA_BUNDLE", bundle)
122
+ os.environ.setdefault("GDAL_CURL_CA_BUNDLE", bundle)
123
+ os.environ.setdefault("SSL_CERT_FILE", bundle)
124
+
125
+
126
+ def _bundled_lib():
127
+ lib_dir = Path(__file__).parent / "_lib"
128
+ for pattern in _LIB_GLOBS:
129
+ for path in sorted(lib_dir.glob(pattern)):
130
+ return str(path)
131
+ return None
132
+
133
+
134
+ def _load_lib():
135
+ # RUMI_LIB beats everything :D, then the bundled wheel copy, then the OS path.
136
+ env_path = os.environ.get("RUMI_LIB")
137
+ candidate = env_path or _bundled_lib() or ctypes.util.find_library("rumi")
138
+ if candidate is None:
139
+ raise OSError(
140
+ "librumi not found. Install it or set RUMI_LIB to its path."
141
+ )
142
+ try:
143
+ return ffi.dlopen(candidate)
144
+ except OSError as exc:
145
+ raise OSError(
146
+ f"failed to load librumi from {candidate!r}: {exc}. "
147
+ "It links GDAL; put a compatible libgdal on the loader path."
148
+ ) from exc
149
+
150
+
151
+ _ensure_ca_bundle()
152
+ lib = _load_lib()
153
+
154
+
155
+ _STATUS_TO_EXC = {
156
+ lib.RUMI_ERR_INVALID: ValueError,
157
+ lib.RUMI_ERR_IO: IOError,
158
+ lib.RUMI_ERR_PARSE: ValueError,
159
+ lib.RUMI_ERR_FORMAT: ValueError,
160
+ lib.RUMI_ERR_DECODE: IOError,
161
+ lib.RUMI_ERR_OOM: MemoryError,
162
+ lib.RUMI_ERR_UNSUPPORTED: NotImplementedError,
163
+ lib.RUMI_ERR_INTERNAL: RuntimeError,
164
+ }
165
+
166
+
167
+ def _check(rc):
168
+ if rc == lib.RUMI_OK:
169
+ return
170
+ err = lib.rumi_last_error()
171
+ msg = (ffi.string(err).decode("utf-8", errors="replace")
172
+ if err != ffi.NULL else "(no error message)")
173
+ raise _STATUS_TO_EXC.get(rc, RuntimeError)(msg)
@@ -0,0 +1,159 @@
1
+ import os
2
+ from collections.abc import Sequence
3
+
4
+ import numpy as np
5
+
6
+ from ._ffi import _check, ffi, lib
7
+ from ._spec import Spec
8
+
9
+ # (start, stop) slice, explicit 0-based indices, or None for all.
10
+ Axis = tuple[int, int] | list[int] | None
11
+ PathLike = str | bytes | os.PathLike
12
+
13
+
14
+ def _enc(path: PathLike) -> bytes:
15
+ return path.encode("utf-8") if isinstance(path, str) else os.fsencode(path)
16
+
17
+
18
+ # convert to 1-based for the C API. None means all and gets passed through as NULL/0.
19
+ def _resolve_axis(sel: Axis, name: str, total: int) -> list[int] | None:
20
+ if sel is None:
21
+ return None
22
+ if isinstance(sel, tuple):
23
+ if len(sel) != 2:
24
+ raise ValueError(f"{name}: tuple must be (start, stop)")
25
+ start, stop = sel
26
+ if not (0 <= start < stop <= total):
27
+ raise ValueError(f"{name}: slice ({start}, {stop}) out of [0, {total}]")
28
+ return list(range(start + 1, stop + 1))
29
+ if isinstance(sel, list):
30
+ out = [int(i) + 1 for i in sel]
31
+ for x in out:
32
+ if not (1 <= x <= total):
33
+ raise ValueError(f"{name}: index {x - 1} out of [0, {total})")
34
+ return out
35
+ raise TypeError(f"{name}: expected tuple or list, got {type(sel).__name__}")
36
+
37
+
38
+ def _resolve_window(sel: tuple[int, int] | None, name: str,
39
+ total: int) -> tuple[int, int]:
40
+ if sel is None:
41
+ return 0, total
42
+ if isinstance(sel, tuple) and len(sel) == 2:
43
+ start, stop = sel
44
+ if not (0 <= start < stop <= total):
45
+ raise ValueError(f"{name}: window ({start}, {stop}) out of [0, {total}]")
46
+ return start, stop - start
47
+ raise TypeError(f"{name}: expected (start, stop) tuple")
48
+
49
+
50
+ def _to_c(lst: list[int] | None):
51
+ if lst is None:
52
+ return ffi.NULL, 0
53
+ return ffi.new("int[]", lst), len(lst)
54
+
55
+
56
+ def index_file(path: PathLike) -> bytes:
57
+ blob_out = ffi.new("unsigned char**")
58
+ size_out = ffi.new("size_t*")
59
+ _check(lib.rumi_index_file(_enc(path), blob_out, size_out))
60
+ try:
61
+ return bytes(ffi.buffer(blob_out[0], size_out[0]))
62
+ finally:
63
+ lib.rumi_free(blob_out[0])
64
+
65
+
66
+ def parse(blob: bytes | bytearray | memoryview) -> Spec:
67
+ return Spec(blob)
68
+
69
+
70
+ def _read_one(path: PathLike, spec: Spec, pattern: str | None,
71
+ b: Axis, y: tuple[int, int] | None, x: tuple[int, int] | None,
72
+ num_threads: int) -> np.ndarray:
73
+ h = spec._header
74
+ bands = _resolve_axis(b, "b", h.samples_per_pixel)
75
+ y_off, y_size = _resolve_window(y, "y", h.image_length)
76
+ x_off, x_size = _resolve_window(x, "x", h.image_width)
77
+
78
+ n_bands = len(bands) if bands is not None else h.samples_per_pixel
79
+ if pattern is None:
80
+ pattern = "b y x"
81
+
82
+ layout = ffi.new("rumi_layout*")
83
+ _check(lib.rumi_compile_layout(
84
+ pattern.encode("ascii"), 1, n_bands, y_size, x_size, layout
85
+ ))
86
+ shape = tuple(layout.shape[i] for i in range(layout.ndim))
87
+ arr = np.empty(shape, dtype=spec._dtype)
88
+
89
+ bands_c, n_bands_c = _to_c(bands)
90
+ _check(lib.rumi_read(
91
+ _enc(path), spec._handle, bands_c, n_bands_c,
92
+ y_off, y_size, x_off, x_size,
93
+ pattern.encode("ascii"), num_threads,
94
+ ffi.cast("void*", arr.ctypes.data), arr.nbytes,
95
+ ))
96
+ return arr
97
+
98
+
99
+ def _read_stack(paths: Sequence[PathLike], specs: Sequence[Spec],
100
+ pattern: str | None, n: Axis, b: Axis,
101
+ y: tuple[int, int] | None, x: tuple[int, int] | None,
102
+ num_threads: int) -> np.ndarray:
103
+ paths = list(paths)
104
+ specs = list(specs)
105
+ if len(paths) != len(specs):
106
+ raise ValueError(
107
+ f"paths and specs length mismatch: {len(paths)} vs {len(specs)}"
108
+ )
109
+ if not paths:
110
+ raise ValueError("read requires at least one image")
111
+
112
+ h = specs[0]._header
113
+ n_sel = _resolve_axis(n, "n", len(specs))
114
+ bands = _resolve_axis(b, "b", h.samples_per_pixel)
115
+ y_off, y_size = _resolve_window(y, "y", h.image_length)
116
+ x_off, x_size = _resolve_window(x, "x", h.image_width)
117
+
118
+ n_count = len(n_sel) if n_sel is not None else len(specs)
119
+ n_bands = len(bands) if bands is not None else h.samples_per_pixel
120
+ if pattern is None:
121
+ pattern = "n b y x" if n_count > 1 else "b y x"
122
+
123
+ layout = ffi.new("rumi_layout*")
124
+ _check(lib.rumi_compile_layout(
125
+ pattern.encode("ascii"), n_count, n_bands, y_size, x_size, layout
126
+ ))
127
+ shape = tuple(layout.shape[i] for i in range(layout.ndim))
128
+ arr = np.empty(shape, dtype=specs[0]._dtype)
129
+
130
+ paths_c = [ffi.new("char[]", _enc(p)) for p in paths]
131
+ paths_arr = ffi.new("char*[]", paths_c)
132
+ specs_arr = ffi.new("rumi_spec*[]", [s._handle for s in specs])
133
+
134
+ n_c, n_count_c = _to_c(n_sel)
135
+ bands_c, n_bands_c = _to_c(bands)
136
+ _check(lib.rumi_read_stack(
137
+ paths_arr, specs_arr, len(specs),
138
+ n_c, n_count_c, bands_c, n_bands_c,
139
+ y_off, y_size, x_off, x_size,
140
+ pattern.encode("ascii"), num_threads,
141
+ ffi.cast("void*", arr.ctypes.data), arr.nbytes,
142
+ ))
143
+ return arr
144
+
145
+
146
+ # Polymorphic and stateless. A single path/spec reads one image, lists read a
147
+ # stack. Opens, reads, closes, with nothing kept alive between calls.
148
+ def read(paths: PathLike | Sequence[PathLike],
149
+ specs: Spec | Sequence[Spec],
150
+ pattern: str | None = None, *,
151
+ n: Axis = None, b: Axis = None,
152
+ y: tuple[int, int] | None = None,
153
+ x: tuple[int, int] | None = None,
154
+ num_threads: int = 1) -> np.ndarray:
155
+ if isinstance(paths, (str, bytes, os.PathLike)):
156
+ if n is not None:
157
+ raise ValueError("n applies to a stack; pass lists of paths/specs")
158
+ return _read_one(paths, specs, pattern, b, y, x, num_threads)
159
+ return _read_stack(paths, specs, pattern, n, b, y, x, num_threads)
@@ -0,0 +1,114 @@
1
+ import html
2
+ import itertools
3
+
4
+ _FACE = "#FAEEDA"
5
+ _TOP = "#FAC775"
6
+ _SIDE = "#EF9F27"
7
+ _LINE = "#854F0B"
8
+ _EDGE = "#633806"
9
+
10
+ _counter = itertools.count()
11
+
12
+
13
+ # At depth t the band slice is front-top-left -> front-top-right ->
14
+ # front-bottom-right, each shifted by t*(33, -33).
15
+ def _sheet(t):
16
+ dx, dy = 33 * t, -33 * t
17
+ return (f'<polyline points="{55+dx},{72+dy} {145+dx},{72+dy} {145+dx},{185+dy}" '
18
+ f'fill="none" stroke="{_EDGE}" stroke-width="1" opacity=".78"/>')
19
+
20
+
21
+ def _bands(b):
22
+ if b <= 1:
23
+ return ""
24
+ if b < 10:
25
+ return "".join(_sheet(k / b) for k in range(1, b))
26
+ fronts = [0.06, 0.12, 0.18, 0.24] if b <= 50 else \
27
+ [0.04, 0.07, 0.10, 0.13, 0.16, 0.19]
28
+ sheets = "".join(_sheet(t) for t in fronts) + _sheet(0.88)
29
+ dots = "".join(f'<circle cx="{105+i*8}" cy="50" r="2.1" fill="{_EDGE}"/>'
30
+ for i in range(3))
31
+ return sheets + dots
32
+
33
+
34
+ def _cube(b, x, y):
35
+ return (
36
+ '<svg width="100%" viewBox="0 0 215 205" '
37
+ f'role="img" aria-label="rumi image cube, {b} bands">'
38
+ f'<polygon points="55,72 88,39 178,39 145,72" fill="{_TOP}" '
39
+ f'stroke="{_LINE}" stroke-width="1.3"/>'
40
+ f'<polygon points="145,72 178,39 178,152 145,185" fill="{_SIDE}" '
41
+ f'stroke="{_LINE}" stroke-width="1.3"/>'
42
+ f'{_bands(b)}'
43
+ f'<rect x="55" y="72" width="90" height="113" fill="{_FACE}" '
44
+ f'stroke="{_EDGE}" stroke-width="1.5"/>'
45
+ f'<g stroke="{_LINE}" stroke-width="0.5" opacity=".35">'
46
+ '<line x1="85" y1="72" x2="85" y2="185"/>'
47
+ '<line x1="115" y1="72" x2="115" y2="185"/>'
48
+ '<line x1="55" y1="110" x2="145" y2="110"/>'
49
+ '<line x1="55" y1="147" x2="145" y2="147"/></g>'
50
+ f'<text x="100" y="199" text-anchor="middle" font-size="10.5" '
51
+ f'font-family="monospace" fill="currentColor" opacity=".7">X: {x}</text>'
52
+ f'<text x="44" y="128" text-anchor="middle" font-size="10.5" '
53
+ f'font-family="monospace" fill="currentColor" opacity=".7" '
54
+ f'transform="rotate(-90,44,128)">Y: {y}</text>'
55
+ f'<text x="182" y="37" font-size="10.5" font-family="monospace" '
56
+ f'fill="currentColor" opacity=".7">B: {b}</text>'
57
+ '</svg>'
58
+ )
59
+
60
+
61
+ _CSS = """
62
+ #ID{font-family:ui-monospace,Menlo,monospace;font-size:13px;color:inherit;
63
+ display:inline-block;line-height:1.5}
64
+ #ID .box{display:flex;gap:6px;align-items:center;
65
+ background:rgba(128,128,128,.06);border:1px solid rgba(128,128,128,.25);
66
+ border-radius:8px;padding:12px 16px}
67
+ #ID .hdr{margin-bottom:9px}
68
+ #ID .cls{opacity:.6}
69
+ #ID .dim{font-weight:600}
70
+ #ID table{border-collapse:collapse;font-size:12.5px}
71
+ #ID td.k{opacity:.6;padding:2px 16px 2px 0}
72
+ #ID td.sub{opacity:.45;padding-left:10px}
73
+ #ID .g{flex:0 0 auto;width:170px}
74
+ """
75
+
76
+
77
+ def _wrap(inner):
78
+ uid = f"rumi{next(_counter)}"
79
+ return (f'<div class="rumi-repr" id="{uid}"><style>'
80
+ f'{_CSS.replace("#ID", f"#{uid}")}</style>{inner}</div>')
81
+
82
+
83
+ def text(f):
84
+ if not f["ok"]:
85
+ return "<rumi.Spec (unreadable)>"
86
+ tw, tl = f["tile"]
87
+ return "\n".join([
88
+ f"<rumi.Spec ({f['b']}, {f['y']}, {f['x']})>",
89
+ f" dtype : {f['dtype']}",
90
+ f" tile : {tw} x {tl}",
91
+ f" tiles : {f['tiles']}",
92
+ f" tiles/band : {f['across'] * f['down']}",
93
+ f" codec : {f['codec']}",
94
+ ])
95
+
96
+
97
+ def html_(f):
98
+ if not f["ok"]:
99
+ return _wrap('<div class="hdr"><span class="cls">rumi.Spec</span> '
100
+ '<span class="dim">(unreadable)</span></div>')
101
+ tw, tl = f["tile"]
102
+ e = html.escape
103
+ rows = (
104
+ f'<tr><td class="k">dtype</td><td>{e(f["dtype"])}</td></tr>'
105
+ f'<tr><td class="k">tile</td><td>{tw} \u00d7 {tl}</td></tr>'
106
+ f'<tr><td class="k">tiles</td><td><b>{f["tiles"]:,}</b></td></tr>'
107
+ f'<tr><td class="k">tiles/band</td><td>{f["across"] * f["down"]:,}</td></tr>'
108
+ f'<tr><td class="k">codec</td><td>{e(f["codec"])}</td></tr>'
109
+ )
110
+ meta = (f'<div><div class="hdr"><span class="cls">rumi.Spec</span> '
111
+ f'<span class="dim">({f["b"]}, {f["y"]}, {f["x"]})</span></div>'
112
+ f'<table>{rows}</table></div>')
113
+ return _wrap(f'<div class="box">{meta}'
114
+ f'<div class="g">{_cube(f["b"], f["x"], f["y"])}</div></div>')
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+
3
+ from . import _repr
4
+ from ._dtype import np_dtype
5
+ from ._ffi import _check, ffi, lib
6
+
7
+
8
+ class Spec:
9
+ """Parsed header blob. Pure memory, no file handle, reusable across reads."""
10
+
11
+ def __init__(self, blob: bytes | bytearray | memoryview) -> None:
12
+ if not isinstance(blob, (bytes, bytearray, memoryview)):
13
+ raise TypeError(f"blob must be bytes-like, got {type(blob).__name__}")
14
+
15
+ blob_buf = ffi.from_buffer("unsigned char[]", blob)
16
+ handle_out = ffi.new("rumi_spec**")
17
+ _check(lib.rumi_spec_parse(blob_buf, len(blob), handle_out))
18
+ # ffi.gc frees the handle whenever it goes away, even mid-__init__.
19
+ self._handle = ffi.gc(handle_out[0], lib.rumi_spec_destroy)
20
+
21
+ header = ffi.new("rumi_header*")
22
+ _check(lib.rumi_spec_header(self._handle, header))
23
+ self._header = header
24
+ self._dtype = np_dtype(header.sample_format, header.bits_per_sample)
25
+
26
+ @property
27
+ def shape(self) -> tuple[int, int, int]:
28
+ h = self._header
29
+ return (h.samples_per_pixel, h.image_length, h.image_width)
30
+
31
+ @property
32
+ def dtype(self) -> type[np.generic]:
33
+ return self._dtype
34
+
35
+ def _facts(self) -> dict:
36
+ try:
37
+ h = self._header
38
+ return {
39
+ "ok": True,
40
+ "b": h.samples_per_pixel, "y": h.image_length, "x": h.image_width,
41
+ "dtype": self._dtype.__name__,
42
+ "tile": (h.tile_width, h.tile_length),
43
+ "across": h.tiles_across, "down": h.tiles_down,
44
+ "tiles": h.tiles_across * h.tiles_down * h.samples_per_pixel,
45
+ "codec": "OpenZL",
46
+ }
47
+ except Exception:
48
+ return {"ok": False}
49
+
50
+ def __repr__(self) -> str:
51
+ return _repr.text(self._facts())
52
+
53
+ def _repr_html_(self) -> str:
54
+ return _repr.html_(self._facts())
@@ -0,0 +1,215 @@
1
+ import struct
2
+ from collections.abc import Iterable
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from ._dtype import sample_encoding
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Layout:
12
+ """Grid and dtype of a tiled image. The header blob minus the bits the
13
+ writer fills in, base_tiles_offset and the per-tile byte counts."""
14
+
15
+ image_width: int
16
+ image_length: int
17
+ tile_width: int
18
+ tile_length: int
19
+ samples_per_pixel: int
20
+ dtype: np.dtype
21
+
22
+ @property
23
+ def sample_format(self) -> int:
24
+ return sample_encoding(self.dtype)[0]
25
+
26
+ @property
27
+ def bits_per_sample(self) -> int:
28
+ return sample_encoding(self.dtype)[1]
29
+
30
+ @property
31
+ def tiles_across(self) -> int:
32
+ return -(-self.image_width // self.tile_width)
33
+
34
+ @property
35
+ def tiles_down(self) -> int:
36
+ return -(-self.image_length // self.tile_length)
37
+
38
+ @property
39
+ def n_tiles(self) -> int:
40
+ return self.tiles_across * self.tiles_down * self.samples_per_pixel
41
+
42
+
43
+ def tile(arr: np.ndarray, tile: int = 512) -> tuple[np.ndarray, Layout]:
44
+ if arr.ndim != 3:
45
+ raise ValueError(f"expected (B, Y, X), got shape {arr.shape}")
46
+ if tile < 16 or tile % 16:
47
+ raise ValueError(f"tile must be a positive multiple of 16, got {tile}")
48
+ sample_encoding(arr.dtype) # reject unsupported dtype early
49
+
50
+ B, Y, X = arr.shape
51
+ T = tile
52
+ across = -(-X // T)
53
+ down = -(-Y // T)
54
+
55
+ pad_y, pad_x = down * T - Y, across * T - X
56
+ if pad_y or pad_x:
57
+ arr = np.pad(arr, ((0, 0), (0, pad_y), (0, pad_x)))
58
+
59
+ # split Y into (down, T) and X into (across, T), then put samples innermost
60
+ g = arr.reshape(B, down, T, across, T).transpose(1, 3, 0, 2, 4)
61
+ chunks = g.reshape(down * across * B, T, T)
62
+
63
+ return chunks, Layout(X, Y, T, T, B, arr.dtype)
64
+
65
+
66
+ # BigTIFF assembler. The layout is fixed so every offset is known before a byte
67
+ # is written: header, then the single IFD and its out-of-line arrays, then the
68
+ # tile payloads in tile order with nothing between them. Every entry carries its
69
+ # packed payload, so the CRS tags that come from C embed verbatim.
70
+
71
+ _LE = "<"
72
+ _SHORT, _LONG, _LONG8, _DOUBLE, _ASCII = 3, 4, 16, 12, 2
73
+ _TYPE_FMT = {_SHORT: "H", _LONG: "I", _LONG8: "Q", _DOUBLE: "d"}
74
+ _TYPE_SIZE = {_SHORT: 2, _LONG: 4, _LONG8: 8, _DOUBLE: 8, _ASCII: 1}
75
+
76
+ _COMPRESSION_OPENZL = 60000
77
+ _PHOTOMETRIC_MINISBLACK = 1
78
+ _PLANARCONFIG_SEPARATE = 2
79
+ _PREDICTOR_NONE = 1
80
+
81
+ _TILE_OFFSETS = 324
82
+
83
+
84
+ def _pack(type_, values):
85
+ return struct.pack(_LE + _TYPE_FMT[type_] * len(values), *values)
86
+
87
+
88
+ def _entry(tag, type_, values):
89
+ return (tag, type_, len(values), _pack(type_, values))
90
+
91
+
92
+ def _crs_geokeys(crs, pixel_is_point):
93
+ from ._ffi import _check, ffi, lib
94
+
95
+ s = (f"EPSG:{crs}" if isinstance(crs, int) else str(crs)).encode("utf-8")
96
+ out = [(ffi.new("unsigned char**"), ffi.new("size_t*")) for _ in range(3)]
97
+ _check(lib.rumi_geokeys(
98
+ s, 1 if pixel_is_point else 0,
99
+ out[0][0], out[0][1], out[1][0], out[1][1], out[2][0], out[2][1]))
100
+ try:
101
+ return tuple(bytes(ffi.buffer(p[0], n[0])) if n[0] else b""
102
+ for p, n in out)
103
+ finally:
104
+ for p, n in out:
105
+ if n[0]:
106
+ lib.rumi_free(p[0])
107
+
108
+
109
+ def _geo_entries(transform, crs, pixel_is_point):
110
+ if transform is None and crs is None:
111
+ return []
112
+ if (transform is None) != (crs is None):
113
+ raise ValueError("transform and crs must be given together")
114
+
115
+ a, b, c, d, e, f = (float(v) for v in tuple(transform)[:6])
116
+ out = []
117
+ # north up, no rotation: pixel scale + tiepoint, exactly what GDAL emits.
118
+ if b == 0.0 and d == 0.0:
119
+ out.append(_entry(33550, _DOUBLE, [a, -e, 0.0]))
120
+ out.append(_entry(33922, _DOUBLE, [0.0, 0.0, 0.0, c, f, 0.0]))
121
+ else:
122
+ out.append(_entry(34264, _DOUBLE, [a, b, 0.0, c,
123
+ d, e, 0.0, f,
124
+ 0.0, 0.0, 0.0, 0.0,
125
+ 0.0, 0.0, 0.0, 1.0]))
126
+
127
+ dir_b, dbl_b, ascii_b = _crs_geokeys(crs, pixel_is_point)
128
+ out.append((34735, _SHORT, len(dir_b) // 2, dir_b))
129
+ if dbl_b:
130
+ out.append((34736, _DOUBLE, len(dbl_b) // 8, dbl_b))
131
+ if ascii_b:
132
+ out.append((34737, _ASCII, len(ascii_b), ascii_b))
133
+ return out
134
+
135
+
136
+ def write(path, frames: Iterable[bytes], layout: Layout, *,
137
+ transform=None, crs=None, pixel_is_point=False) -> None:
138
+ frames = [memoryview(f) for f in frames]
139
+ if len(frames) != layout.n_tiles:
140
+ raise ValueError(f"expected {layout.n_tiles} frames, got {len(frames)}")
141
+ counts = [f.nbytes for f in frames]
142
+ if any(c == 0 for c in counts):
143
+ raise ValueError("empty tile payload, rumi forbids sparse tiles")
144
+
145
+ B = layout.samples_per_pixel
146
+ sf, bps = layout.sample_format, layout.bits_per_sample
147
+
148
+ # rumi lays the tile bytes out tile-interleaved (samples innermost).
149
+ tpp = layout.tiles_across * layout.tiles_down
150
+ pm_order = [pos * B + b for b in range(B) for pos in range(tpp)]
151
+ counts_pm = [counts[i] for i in pm_order]
152
+
153
+ # (tag, type, count, packed) in ascending tag order. TileOffsets packs once
154
+ # the data offset is known. Geo tags, if any, append above 339.
155
+ entries = [
156
+ _entry(256, _LONG, [layout.image_width]),
157
+ _entry(257, _LONG, [layout.image_length]),
158
+ _entry(258, _SHORT, [bps] * B),
159
+ _entry(259, _SHORT, [_COMPRESSION_OPENZL]),
160
+ _entry(262, _SHORT, [_PHOTOMETRIC_MINISBLACK]),
161
+ _entry(277, _SHORT, [B]),
162
+ _entry(284, _SHORT, [_PLANARCONFIG_SEPARATE]),
163
+ _entry(317, _SHORT, [_PREDICTOR_NONE]),
164
+ _entry(322, _SHORT, [layout.tile_width]),
165
+ _entry(323, _SHORT, [layout.tile_length]),
166
+ (_TILE_OFFSETS, _LONG8, len(frames), None),
167
+ _entry(325, _LONG, counts_pm),
168
+ _entry(339, _SHORT, [sf] * B),
169
+ ]
170
+ entries += _geo_entries(transform, crs, pixel_is_point)
171
+
172
+ ifd_offset = 16
173
+ ifd_size = 8 + 20 * len(entries) + 8
174
+ cursor = ifd_offset + ifd_size
175
+
176
+ ext_offset = {}
177
+ for tag, type_, count, _ in entries:
178
+ size = _TYPE_SIZE[type_] * count
179
+ if size > 8:
180
+ ext_offset[tag] = cursor
181
+ cursor += size + (size & 1)
182
+ base_tiles_offset = cursor
183
+
184
+ off = base_tiles_offset
185
+ tile_offsets = []
186
+ for c in counts:
187
+ tile_offsets.append(off)
188
+ off += c
189
+ offsets_pm = [tile_offsets[i] for i in pm_order]
190
+
191
+ buf = bytearray()
192
+ buf += struct.pack(_LE + "HHHH", 0x4949, 43, 8, 0)
193
+ buf += struct.pack(_LE + "Q", ifd_offset)
194
+ buf += struct.pack(_LE + "Q", len(entries))
195
+
196
+ ext = bytearray()
197
+ for tag, type_, count, packed in entries:
198
+ if tag == _TILE_OFFSETS:
199
+ packed = _pack(_LONG8, offsets_pm)
200
+ buf += struct.pack(_LE + "HHQ", tag, type_, count)
201
+ if len(packed) <= 8:
202
+ buf += packed.ljust(8, b"\x00")
203
+ else:
204
+ buf += struct.pack(_LE + "Q", ext_offset[tag])
205
+ ext += packed
206
+ if len(packed) & 1:
207
+ ext += b"\x00"
208
+ buf += struct.pack(_LE + "Q", 0) # one IFD, no next
209
+ buf += ext
210
+
211
+ assert len(buf) == base_tiles_offset
212
+ with open(path, "wb") as fh:
213
+ fh.write(buf)
214
+ for f in frames:
215
+ fh.write(f)
File without changes