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.
- rumi_eo-0.8.3/.gitignore +73 -0
- rumi_eo-0.8.3/PKG-INFO +33 -0
- rumi_eo-0.8.3/README.md +3 -0
- rumi_eo-0.8.3/hatch_build.py +20 -0
- rumi_eo-0.8.3/pyproject.toml +91 -0
- rumi_eo-0.8.3/rumi/__init__.py +16 -0
- rumi_eo-0.8.3/rumi/_dtype.py +38 -0
- rumi_eo-0.8.3/rumi/_ffi.py +173 -0
- rumi_eo-0.8.3/rumi/_read.py +159 -0
- rumi_eo-0.8.3/rumi/_repr.py +114 -0
- rumi_eo-0.8.3/rumi/_spec.py +54 -0
- rumi_eo-0.8.3/rumi/_write.py +215 -0
- rumi_eo-0.8.3/tests/.gitkeep +0 -0
rumi_eo-0.8.3/.gitignore
ADDED
|
@@ -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.
|
rumi_eo-0.8.3/README.md
ADDED
|
@@ -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
|