cubio 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.
cubio/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Welcome to `cubio`!
3
+ ---
4
+ ### Available Classes
5
+ - CubeContext // Metadata for reading in geospatial cubes.
6
+ - CubeData // Memory-mapped pointer for performing processing with `xarray`.
7
+ """
8
+
9
+ from .cube_context import CubeContext
10
+
11
+ __all__ = ["CubeContext"]
@@ -0,0 +1,9 @@
1
+ from .envi_file_interface import (
2
+ read_spectral_envi_file_context,
3
+ read_measurement_envi_file_context,
4
+ )
5
+
6
+ __all__ = [
7
+ "read_spectral_envi_file_context",
8
+ "read_measurement_envi_file_context",
9
+ ]
@@ -0,0 +1,144 @@
1
+ # Built-Ins
2
+ from pathlib import Path
3
+ from uuid import uuid4
4
+
5
+ # Dependencies
6
+ import rasterio as rio # type: ignore
7
+
8
+ # Local Imports
9
+ from cubio.types import RasterioProfile, NumpyDType
10
+ from cubio.cube_context import CubeContext, ContextBuilder
11
+ from cubio.geotools.models import GeotransformModel
12
+ from cubio.data.crs_wkt_strings import GeographicCRS
13
+ from cubio.envi_hdr_tools import (
14
+ extract_hdr_wavelengths,
15
+ extract_hdr_bbl,
16
+ extract_hdr_band_names,
17
+ extract_hdr_desc,
18
+ )
19
+
20
+
21
+ def read_spectral_envi_file_context(fp: Path | str, name: str) -> CubeContext:
22
+ """
23
+ Reads the context data from an ENVI cube file representing spectral data.
24
+
25
+ Parameters
26
+ ----------
27
+ fp : Path | str
28
+ File path to ENVI cube.
29
+ name : str
30
+ Name of the CubeContext object.
31
+
32
+ Returns
33
+ -------
34
+ CubeContext
35
+ Relevant context data about the cube.
36
+
37
+ Raises
38
+ ------
39
+ ValueError
40
+ If the .hdr file does not contain a wavelength field.
41
+ """
42
+
43
+ with rio.open(fp, "r") as f:
44
+ prf: RasterioProfile = f.profile
45
+
46
+ hdr_fp = Path(fp).with_suffix(".hdr")
47
+ wvls = extract_hdr_wavelengths(hdr_fp)
48
+ desc = extract_hdr_desc(hdr_fp)
49
+ bbl = extract_hdr_bbl(hdr_fp)
50
+ if wvls == "Wavelengths not found.":
51
+ raise ValueError(
52
+ "File is not a spectral envi file. Try"
53
+ "`read_measurement_envi_file_context()`"
54
+ )
55
+ if bbl == "No BBL Found":
56
+ bbl = [1] * len(wvls)
57
+
58
+ if prf["crs"] is None:
59
+ crs = GeographicCRS.GCS_MOON_2000
60
+ else:
61
+ crs = prf["crs"]
62
+
63
+ context_dict: ContextBuilder = {
64
+ "name": name,
65
+ "description": desc,
66
+ "data_filename": Path(Path(fp).name),
67
+ "nrows": prf["height"],
68
+ "ncols": prf["width"],
69
+ "nbands": prf["count"],
70
+ "crs": crs,
71
+ "geotransform": GeotransformModel.fromaffine(prf["transform"]),
72
+ "hdr_off": 0,
73
+ "data_type": NumpyDType.FLOAT32,
74
+ "interleave": "BIL",
75
+ "nodata": -999,
76
+ "band_names": [f"Band{n+1}({i}nm)" for n, i in enumerate(wvls)],
77
+ "measurement_name": "Wavelength",
78
+ "measurement_units": "nm",
79
+ "measurement_values": wvls,
80
+ "bad_bands": bbl,
81
+ "id": uuid4(),
82
+ }
83
+
84
+ return CubeContext.from_builder(context_dict)
85
+
86
+
87
+ def read_measurement_envi_file_context(
88
+ fp: Path | str, name: str
89
+ ) -> CubeContext:
90
+ """
91
+ Reads the context data from an envi file that does not represent a spectral
92
+ dataset, but some other measurement (Location, Geometry, Parameters,
93
+ etc...)
94
+
95
+ Parameters
96
+ ----------
97
+ fp : Path | str
98
+ File path to the ENVI cube.
99
+ name : str
100
+ Name of the CubeContext object.
101
+
102
+ Returns
103
+ -------
104
+ CubeContext
105
+ Resulting context data from the cube.
106
+ """
107
+
108
+ with rio.open(fp, "r") as f:
109
+ prf: RasterioProfile = f.profile
110
+
111
+ hdr_fp = Path(fp).with_suffix(".hdr")
112
+ band_names = extract_hdr_band_names(hdr_fp)
113
+ bbl = extract_hdr_bbl(hdr_fp)
114
+ desc = extract_hdr_desc(hdr_fp)
115
+ if bbl == "No BBL Found":
116
+ bbl = [1] * len(band_names)
117
+
118
+ if prf["crs"] is None:
119
+ crs = GeographicCRS.GCS_MOON_2000
120
+ else:
121
+ crs = prf["crs"]
122
+
123
+ context_dict: ContextBuilder = {
124
+ "name": name,
125
+ "description": desc,
126
+ "data_filename": Path(Path(fp).name),
127
+ "nrows": prf["height"],
128
+ "ncols": prf["width"],
129
+ "nbands": prf["count"],
130
+ "crs": crs,
131
+ "geotransform": GeotransformModel.fromaffine(prf["transform"]),
132
+ "hdr_off": 0,
133
+ "data_type": NumpyDType(prf["dtype"]),
134
+ "interleave": "BIL",
135
+ "nodata": -999,
136
+ "band_names": band_names,
137
+ "measurement_name": name,
138
+ "measurement_units": "unitless",
139
+ "measurement_values": [i for i in range(prf["count"])],
140
+ "bad_bands": bbl,
141
+ "id": uuid4(),
142
+ }
143
+
144
+ return CubeContext.from_builder(context_dict)
cubio/cube_context.py ADDED
@@ -0,0 +1,355 @@
1
+ # Built-ins
2
+ from typing import TypedDict, Literal
3
+ from typing_extensions import Self
4
+ from tempfile import NamedTemporaryFile
5
+ from pathlib import Path
6
+ import shutil
7
+ from uuid import UUID, uuid4
8
+
9
+ # Dependencies
10
+ import numpy as np
11
+ from pydantic import (
12
+ BaseModel,
13
+ field_serializer,
14
+ model_validator,
15
+ Field,
16
+ field_validator,
17
+ PrivateAttr,
18
+ )
19
+ import rasterio as rio # type: ignore
20
+ import xarray as xr
21
+
22
+ # Local Imports
23
+ from cubio.types import (
24
+ NumpyDType,
25
+ CubeArrayFormat,
26
+ is_valid_cubearrayformat,
27
+ suffix_to_format_map,
28
+ RasterioProfile,
29
+ ImageSuffix,
30
+ is_valid_image_suffix,
31
+ image_suffix_priority,
32
+ )
33
+ from cubio.geotools.models import GeotransformModel
34
+ from cubio.envi_hdr_tools import (
35
+ replace_hdr_band_names,
36
+ replace_hdr_description,
37
+ replace_shape_fields,
38
+ )
39
+ from cubio.cube_size_tools import CubeSize
40
+ from cubio.cube_data import CubeData
41
+
42
+
43
+ class ContextBuilder(TypedDict):
44
+ name: str
45
+ description: str
46
+ data_filename: Path
47
+ ncols: int
48
+ nrows: int
49
+ nbands: int
50
+ hdr_off: int
51
+ data_type: NumpyDType
52
+ interleave: CubeArrayFormat
53
+ crs: str
54
+ geotransform: GeotransformModel
55
+ band_names: list[str]
56
+ nodata: float | int
57
+ measurement_name: str
58
+ measurement_units: str
59
+ measurement_values: list[float]
60
+ bad_bands: list[int]
61
+ id: UUID
62
+
63
+
64
+ class CubeContext(BaseModel):
65
+ name: str = Field(..., description="A short name for the cube.")
66
+ description: str = Field(
67
+ ..., description="A description of the data cube."
68
+ )
69
+ data_filename: Path = Field(
70
+ ...,
71
+ description="Filename of the data that is described by this file. It"
72
+ " should be in the same directory as this file.",
73
+ )
74
+ ncols: int = Field(..., description="Number of columns in the data cube.")
75
+ nrows: int = Field(..., description="Number of rows in the data cube.")
76
+ nbands: int = Field(..., description="Number of bands in the data cube.")
77
+ hdr_off: int = Field(default=0, description="Header offset in bytes.")
78
+ data_type: NumpyDType = Field(
79
+ ..., description="The data type of the cube."
80
+ )
81
+ interleave: CubeArrayFormat = Field(
82
+ ...,
83
+ description=(
84
+ "The interleave format of the cube. Either BIL, BIP, or BSQ."
85
+ ),
86
+ )
87
+ crs: str = Field(
88
+ ..., description="Coordinate reference system of the cube."
89
+ )
90
+ geotransform: GeotransformModel = Field(
91
+ ..., description="Geotransform model for the cube."
92
+ )
93
+ band_names: list[str] = Field(default_factory=list)
94
+ nodata: float | int = Field(default=-999, description="The nodata value.")
95
+ measurement_name: str = Field(
96
+ default="Measurement",
97
+ description="Name that describes the nature of the measurement along "
98
+ "the cube z-axis.",
99
+ )
100
+ measurement_units: str = Field(
101
+ default="unitless", description="The measurement units."
102
+ )
103
+ measurement_values: list[float] = Field(
104
+ default_factory=list, description="The measurement values."
105
+ )
106
+ bad_bands: list[int] = Field(
107
+ default_factory=list, description="List of bad band flags."
108
+ )
109
+ id: UUID = Field(
110
+ default_factory=uuid4, description="Unique ID of the cube object."
111
+ )
112
+ _retrieval_path: Path | Literal["NoRetrieval"] = PrivateAttr(
113
+ default="NoRetrieval"
114
+ )
115
+
116
+ @property
117
+ def shape(self) -> CubeSize:
118
+ return CubeSize(
119
+ nrows=self.nrows, ncolumns=self.ncols, nbands=self.nbands
120
+ )
121
+
122
+ @property
123
+ def shape_tuple(self) -> tuple[int, int, int]:
124
+ return self.shape.as_tuple(self.interleave)
125
+
126
+ @property
127
+ def builder(self) -> ContextBuilder:
128
+ _builder: ContextBuilder = {
129
+ "name": self.name,
130
+ "description": self.description,
131
+ "data_filename": self.data_filename,
132
+ "ncols": self.ncols,
133
+ "nrows": self.nrows,
134
+ "nbands": self.nbands,
135
+ "hdr_off": self.hdr_off,
136
+ "data_type": self.data_type,
137
+ "interleave": self.interleave,
138
+ "crs": self.crs,
139
+ "geotransform": self.geotransform,
140
+ "band_names": self.band_names,
141
+ "nodata": self.nodata,
142
+ "measurement_name": self.measurement_name,
143
+ "measurement_units": self.measurement_units,
144
+ "measurement_values": self.measurement_values,
145
+ "bad_bands": self.bad_bands,
146
+ "id": self.id,
147
+ }
148
+ return _builder
149
+
150
+ @classmethod
151
+ def from_builder(cls, builder_dict: ContextBuilder) -> Self:
152
+ return cls(**builder_dict)
153
+
154
+ @classmethod
155
+ def from_json(cls, savefp: str | Path) -> Self:
156
+ """Convenience method for reading in the model from json file."""
157
+ with open(savefp, "r") as f:
158
+ json = f.read()
159
+ valid_model = cls.model_validate_json(json)
160
+ valid_model._retrieval_path = Path(savefp)
161
+ return valid_model
162
+
163
+ @field_serializer("interleave", mode="plain")
164
+ def lowercase(self, value: CubeArrayFormat) -> str:
165
+ return value.lower()
166
+
167
+ @field_validator("interleave", mode="before")
168
+ @classmethod
169
+ def uppercase(cls, value: str) -> CubeArrayFormat:
170
+ ustr = value.upper()
171
+ if is_valid_cubearrayformat(ustr):
172
+ return ustr
173
+ else:
174
+ raise ValueError(f"Invalid interleave: {ustr}")
175
+
176
+ @model_validator(mode="after")
177
+ def set_default_bbl(self) -> Self:
178
+ current_bbl = self.bad_bands
179
+ if len(current_bbl) == 0:
180
+ self.bad_bands = [1] * len(self.measurement_values)
181
+ else:
182
+ self.bad_bands = current_bbl
183
+
184
+ if len(self.bad_bands) != self.nbands:
185
+ print(self.bad_bands)
186
+ raise ValueError(
187
+ "Length of bad band list must match the number of bands."
188
+ )
189
+
190
+ return self
191
+
192
+ @model_validator(mode="after")
193
+ def check_measurement_values(self) -> Self:
194
+ if len(self.measurement_values) != self.nbands:
195
+ raise ValueError(
196
+ "Length of measurement values must match the number of bands."
197
+ )
198
+ return self
199
+
200
+ def write_envi_hdr(
201
+ self, dst: Path | str | None = None, use_image_name: bool = True
202
+ ) -> None:
203
+ """
204
+ Writes the model to a properly formatted ENVI header file.
205
+
206
+ Parameters
207
+ ----------
208
+ dst : Path | str | None, optional
209
+ Destination file path, by default None.
210
+
211
+ Notes
212
+ -----
213
+ The file suffix is set internally, so the dst file does not need to
214
+ include one.
215
+ """
216
+
217
+ # A temporary file should be used if no path is provided.
218
+ if dst is None:
219
+ tf = NamedTemporaryFile(suffix=".hdr", delete=False)
220
+ savefp = Path(tf.name)
221
+ else:
222
+ savefp = Path(dst)
223
+
224
+ # Adding suffix based on the use_image_name option.
225
+ if use_image_name:
226
+ if savefp.is_dir():
227
+ savefp = Path(savefp, self.data_filename).with_suffix(".hdr")
228
+ else:
229
+ savefp = savefp.with_name(str(self.data_filename)).with_suffix(
230
+ ".hdr"
231
+ )
232
+ else:
233
+ savefp = savefp.with_suffix(".hdr")
234
+
235
+ # Need a temporary image and hdr file to write to. These will be empty.
236
+ temp_img = savefp.with_name(f"_{savefp.name}").with_suffix(".img")
237
+ temp_hdr = temp_img.with_suffix(".hdr")
238
+
239
+ # Need a rasterio profile to do a proper write.
240
+ envi_profile: RasterioProfile = {
241
+ "driver": "ENVI",
242
+ "width": 1,
243
+ "height": 1,
244
+ "count": 1,
245
+ "dtype": self.data_type,
246
+ "crs": self.crs,
247
+ "transform": self.geotransform.toaffine(),
248
+ "interleave": self.interleave,
249
+ "nodata": -999,
250
+ }
251
+
252
+ # A blank image write is needed. We just use the .hdr file.
253
+ with rio.open(temp_img, "w", **envi_profile) as f:
254
+ f.write(np.empty((1, 1, 1)))
255
+
256
+ temp_img.unlink() # Discarding temporary image.
257
+
258
+ # Modifying rasterio-written .hdr file.
259
+ # These are existing fields.
260
+ replace_shape_fields(
261
+ temp_hdr,
262
+ samples=self.ncols,
263
+ lines=self.nrows,
264
+ bands=self.nbands,
265
+ )
266
+ replace_hdr_band_names(temp_hdr, self.band_names)
267
+ replace_hdr_description(temp_hdr, self.description)
268
+
269
+ with open(temp_hdr, "a") as f:
270
+ # These are all new fields.
271
+ f.write(f"data ignore value = {self.nodata}\n")
272
+ f.write(f"wavelenth units = {self.measurement_units}\n")
273
+ f.write(
274
+ "wavelength = "
275
+ f"{{{",".join([str(i) for i in self.measurement_values])}}}\n"
276
+ )
277
+ f.write(
278
+ "bbl = " f"{{{",".join([str(i) for i in self.bad_bands])}}}\n"
279
+ )
280
+ # Renaming the temporary hdr that was created with the temp image.
281
+ shutil.move(temp_hdr, temp_hdr.with_name(temp_hdr.name[1:]))
282
+
283
+ # Writing out the pydantic model in json form for easy reading.
284
+ self.write_json(savefp)
285
+
286
+ def write_json(self, savefp: str | Path) -> None:
287
+ """Convenience function for dumping the model to a json file."""
288
+ json_str = self.model_dump_json(indent=2)
289
+ with open(Path(savefp).with_suffix(".json"), "w") as f:
290
+ f.write(json_str)
291
+
292
+ def lazy_load_data(self, search_dir: str | Path | None = None) -> CubeData:
293
+ load_from: Path
294
+ if (search_dir is not None) and (
295
+ self._retrieval_path == "NoRetrieval"
296
+ ):
297
+ load_from = Path(search_dir)
298
+ elif (self._retrieval_path != "NoRetrieval") and (search_dir is None):
299
+ load_from = self._retrieval_path.parent
300
+ else:
301
+ raise ValueError(
302
+ "If `savefp` is specified, the object must not have been "
303
+ "validated from disk."
304
+ )
305
+
306
+ if not load_from.is_dir():
307
+ raise ValueError(f"Search directory does not exist: {load_from}")
308
+
309
+ dat = CubeData(
310
+ self.name,
311
+ self.interleave,
312
+ z_labels=self.measurement_values,
313
+ z_name=self.measurement_name,
314
+ geotransform=self.geotransform,
315
+ )
316
+ candidate_image_data_files: list[tuple[Path, ImageSuffix]] = []
317
+ for i in load_from.iterdir():
318
+ if i.stem == str(self.data_filename.stem):
319
+ suff = i.suffix.lower()
320
+ if is_valid_image_suffix(suff):
321
+ candidate_image_data_files.append((i, suff))
322
+
323
+ candidate_image_data_files.sort(
324
+ key=lambda item: image_suffix_priority[item[1]]
325
+ )
326
+
327
+ image_data_file = candidate_image_data_files[0][0]
328
+
329
+ interleave_test = suffix_to_format_map.get(image_data_file.suffix)
330
+
331
+ if (interleave_test is not None) and (
332
+ interleave_test != self.interleave
333
+ ):
334
+ raise ValueError(
335
+ f"Loaded data ({image_data_file}) does not match the"
336
+ f"registered interleave ({self.interleave})"
337
+ )
338
+
339
+ if image_data_file.suffix.lower() in [".img", ".bsq", ".bil", ".bip"]:
340
+ mmap = np.memmap(
341
+ image_data_file,
342
+ dtype=np.dtype(self.data_type),
343
+ shape=self.shape_tuple,
344
+ )
345
+ dat.array = xr.DataArray(mmap)
346
+ elif image_data_file.suffix.lower() == ".hdf5":
347
+ raise NotImplementedError("HDF5 file not implemented yet.")
348
+ elif image_data_file.suffix.lower() == ".zarr":
349
+ arr = xr.open_dataarray(image_data_file, engine="zarr")
350
+ dat.yname = str(arr.dims[0])
351
+ dat.xname = str(arr.dims[1])
352
+ dat.zname = str(arr.dims[2])
353
+ dat.array = arr
354
+
355
+ return dat