kuva-reader 0.1.0__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.

Potentially problematic release.


This version of kuva-reader might be problematic. Click here for more details.

@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: kuva-reader
3
+ Version: 0.1.0
4
+ Summary: Manipulate the Kuva Space image and metadata formats
5
+ License: MIT
6
+ Author: Guillem Ballesteros
7
+ Author-email: guillem@kuvaspace.com
8
+ Requires-Python: >=3.10,<=3.13
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: dask (>=2023.12.1,<2024.0.0)
16
+ Requires-Dist: kuva-geometry
17
+ Requires-Dist: kuva-metadata
18
+ Requires-Dist: numpy (>=1.26.4,<2.0.0)
19
+ Requires-Dist: numpy-quaternion (>=2022.4.4,<2023.0.0)
20
+ Requires-Dist: pint (>=0.22,<0.23)
21
+ Requires-Dist: psycopg (>=3.2.3,<4.0.0)
22
+ Requires-Dist: rasterio (>=1.4.1,<2.0.0)
23
+ Requires-Dist: rioxarray (>=0.12.4,<0.13.0)
24
+ Requires-Dist: xarray (>=2022.12.0,<2023.0.0)
@@ -0,0 +1,44 @@
1
+ """
2
+ Kuva Reader provides functionality for opening and accessing Kuva Space Earth
3
+ Observation (EO) products. The module handles the reading and parsing of image
4
+ data, as well as extracting and structuring the associated metadata to
5
+ facilitate further analysis or visualization.
6
+
7
+ ## Key Features
8
+
9
+ - **Open EO Products**: Load satellite images and corresponding metadata from
10
+ various data formats.
11
+ - **Access Metadata**: Retrieve information such as acquisition time, satellite
12
+ name, sensor type, geospatial coordinates, and any custom metadata embedded
13
+ within the product.
14
+ - **Image Handling**: Manage the loading of image data for efficient use in
15
+ analytical processes.
16
+
17
+ ## Dependencies
18
+ - **kuva-metadata**: A specialized library that handles the extraction and
19
+ parsing of metadata associated with Kuva Space products.
20
+ - **xarray**: Used for loading image data as arrays with extra functionality,
21
+ including labeled coordinates and metadata, which is useful for analysis and
22
+ visualization.
23
+ """
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ from .reader.image import (
28
+ image_to_dtype_range,
29
+ image_to_original_range,
30
+ image_to_uint16_range,
31
+ )
32
+ from .reader.level0 import Level0Product
33
+ from .reader.level1 import Level1ABProduct, Level1CProduct
34
+ from .reader.level2 import Level2AProduct
35
+
36
+ __all__ = [
37
+ "Level0Product",
38
+ "Level1ABProduct",
39
+ "Level1CProduct",
40
+ "Level2AProduct",
41
+ "image_to_dtype_range",
42
+ "image_to_original_range",
43
+ "image_to_uint16_range",
44
+ ]
File without changes
File without changes
@@ -0,0 +1,176 @@
1
+ """Utilities to process images related to product processing."""
2
+
3
+ from typing import cast, overload
4
+
5
+ import numpy as np
6
+ import xarray
7
+
8
+ # Helper type for image processing purposes. The same operations work both for EO
9
+ # DataArrays and Numpy arrays.
10
+ ImageArray_ = np.ndarray | xarray.DataArray
11
+
12
+
13
+ @overload
14
+ def image_to_dtype_range(
15
+ img: np.ndarray,
16
+ dtype: np.dtype,
17
+ offset: float | None = None,
18
+ scale: float | None = None,
19
+ ) -> tuple[xarray.DataArray, float, float]: ...
20
+
21
+
22
+ @overload
23
+ def image_to_dtype_range(
24
+ img: xarray.DataArray,
25
+ dtype: np.dtype,
26
+ offset: float | None = None,
27
+ scale: float | None = None,
28
+ ) -> tuple[xarray.DataArray, float, float]: ...
29
+
30
+
31
+ def image_to_dtype_range(
32
+ img: ImageArray_,
33
+ dtype: np.dtype,
34
+ offset: float | None = None,
35
+ scale: float | None = None,
36
+ ) -> tuple[ImageArray_, float, float]:
37
+ """Normalize an image to the bounds of whatever numpy datatype. E.g. np.uint16
38
+ results in a np.uint16 image with values between entire range [0, 65535]
39
+
40
+ Parameters
41
+ ----------
42
+ img
43
+ Image to normalize
44
+ dtype
45
+ Target data type, only integer subtypes currently sensible and are supported
46
+ offset, optional
47
+ Offset if that was already precomputed. If not, it will be calculated from `arr`
48
+ scale, optional
49
+ Scale if that was already precomputed. If not, it will be calculated from `arr`
50
+
51
+ Returns
52
+ -------
53
+ The normalized image along casted to given data type, along with the offset and
54
+ scale used to normalize it
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ Unsupported data type
60
+ """
61
+ if np.issubdtype(dtype, np.integer):
62
+ type_info = np.iinfo(dtype)
63
+ else:
64
+ e_ = f"Unsupported dtype {dtype} for normalization"
65
+ raise ValueError(e_)
66
+
67
+ dtype_min = type_info.min
68
+ dtype_max = type_info.max
69
+
70
+ if offset is None or scale is None:
71
+ offset_ = cast(float, np.min(img))
72
+ scale_ = cast(float, np.max(img) - offset_)
73
+ else:
74
+ offset_ = offset
75
+ scale_ = scale
76
+
77
+ normed_to_0_1 = (img - offset_) / scale_
78
+
79
+ normalized_image = normed_to_0_1 * (dtype_max - dtype_min) + dtype_min
80
+ normalized_image = normalized_image.astype(dtype)
81
+
82
+ return normalized_image, offset_, scale_
83
+
84
+
85
+ @overload
86
+ def image_to_uint16_range(img: np.ndarray) -> tuple[np.ndarray, float, float]: ...
87
+
88
+
89
+ @overload
90
+ def image_to_uint16_range(
91
+ img: xarray.DataArray,
92
+ ) -> tuple[xarray.DataArray, float, float]: ...
93
+
94
+
95
+ def image_to_uint16_range(img: ImageArray_) -> tuple[ImageArray_, float, float]:
96
+ """Normalise image to bounds of uint16, see above function for details
97
+
98
+ Parameters
99
+ ----------
100
+ img
101
+ Image to normalize
102
+
103
+ Returns
104
+ -------
105
+ The normalized image along casted to given data type, along with the offset and
106
+ scale used to normalize it
107
+ """
108
+ return image_to_dtype_range(img, np.dtype(np.uint16))
109
+
110
+
111
+ @overload
112
+ def image_to_original_range(
113
+ img: np.ndarray,
114
+ offset: float,
115
+ scale: float,
116
+ dtype: np.dtype | None = None,
117
+ ) -> xarray.DataArray: ...
118
+
119
+
120
+ @overload
121
+ def image_to_original_range(
122
+ img: xarray.DataArray,
123
+ offset: float,
124
+ scale: float,
125
+ dtype: np.dtype | None = None,
126
+ ) -> xarray.DataArray: ...
127
+
128
+
129
+ def image_to_original_range(
130
+ img: ImageArray_,
131
+ offset: float,
132
+ scale: float,
133
+ dtype: np.dtype | None = None,
134
+ ) -> ImageArray_:
135
+ """Revert normalisation applied to an image. The image 'arr' must have the same
136
+ data type as the result from normalization, or it must be given separately
137
+
138
+ Parameters
139
+ ----------
140
+ arr
141
+ Image to revert back to original values
142
+ offset
143
+ Offset that was applied to the image
144
+ scale
145
+ Scale that was applied to the image
146
+ dtype, optional
147
+ The data type that the image was casted to during normalization, by default None
148
+ where the data type of `arr` will be assumed to be correct.
149
+
150
+ Returns
151
+ -------
152
+ Image that is back in original range of values before normalization
153
+
154
+ Raises
155
+ ------
156
+ ValueError
157
+ Unsupported data type
158
+ """
159
+ if not dtype:
160
+ dtype = img.dtype
161
+
162
+ # Check real bounds from numpy data types
163
+ if np.issubdtype(dtype, np.integer) and isinstance(dtype, np.dtype):
164
+ type_info = np.iinfo(dtype)
165
+ else:
166
+ e_ = f"Unsupported dtype {dtype} for normalization"
167
+ raise ValueError(e_)
168
+
169
+ dtype_min = type_info.min
170
+ dtype_max = type_info.max
171
+
172
+ # Reverse the normalization
173
+ denormed_to_0_1 = (img - dtype_min) / (dtype_max - dtype_min)
174
+ original_image = denormed_to_0_1 * scale + offset
175
+
176
+ return original_image
@@ -0,0 +1,238 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ import numpy as np
5
+ import rioxarray as rx
6
+ import xarray
7
+ from kuva_metadata import MetadataLevel0
8
+ from pint import UnitRegistry
9
+
10
+ from kuva_reader import image_to_dtype_range, image_to_original_range
11
+
12
+ from .product_base import ProductBase
13
+
14
+
15
+ class Level0Product(ProductBase[MetadataLevel0]):
16
+ """
17
+ Level 0 products contain the raw data acquired from the sensor. They
18
+ consist of one roughly georeferenced geotiff per camera and the associated
19
+ metadata. Changes to them are only performed at the metadata level to avoid
20
+ deteriorating them.
21
+
22
+ At this processing level frames are not aligned, a natural consequence of
23
+ satellite motion, and are therefore not very useful for any activity that
24
+ require working with more than one band simultaneously. In that case you
25
+ should look into using L1 products.
26
+
27
+ The data in the image files is lazy loaded to make things snappier for end
28
+ users but may lead to surprising behaviour if you are not aware of it
29
+
30
+
31
+ Parameters
32
+ ----------
33
+ image_path
34
+ Path to the folder containing the L0 product images
35
+ metadata, optional
36
+ Metadata if already read e.g. from a database. By default None, meaning
37
+ automatic fetching from metadata sidecar file
38
+ target_ureg, optional
39
+ Pint Unit Registry to swap to. This is only relevant when parsing data from a
40
+ JSON file, which by default uses the kuva-metadata ureg.
41
+ as_physical_unit
42
+ Whether to denormalize data from full data type range back to the physical
43
+ units stored with the data, by default False
44
+ target_dtype
45
+ Target data type to normalize data to. This will first denormalize the data
46
+ to its original range and then normalize to new data type range to keep a
47
+ scale and offset, by default None
48
+
49
+ Attributes
50
+ ----------
51
+ image_path: Path
52
+ Path to the folder containing the images.
53
+ metadata: MetadataLevel0
54
+ The metadata associated with the images
55
+ images: Dict[str, xarray.DataArray]
56
+ The arrays with the actual data. This have the rioxarray extension activated on
57
+ them so lots of GIS functionality are available on them. Imporantly, the GCPs
58
+ can be retrieved like so: `ds.rio.get_gcps()`
59
+ data_tags: Dict[str, Any]
60
+ Tags stored along with the data. These can be used e.g. to check the physical
61
+ units of pixels or normalisation factors.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ image_path: Path,
67
+ metadata: MetadataLevel0 | None = None,
68
+ target_ureg: UnitRegistry | None = None,
69
+ as_physical_unit: bool = False,
70
+ target_dtype: np.dtype | None = None,
71
+ ) -> None:
72
+ super().__init__(image_path, metadata, target_ureg)
73
+
74
+ self.images = {
75
+ camera: cast(
76
+ xarray.DataArray,
77
+ rx.open_rasterio(
78
+ self.image_path / (cube.camera.name + ".tif"),
79
+ ),
80
+ )
81
+ for camera, cube in self.metadata.image.data_cubes.items() # type: ignore
82
+ }
83
+
84
+ # Read tags for images and denormalize / renormalize if needed
85
+ self.data_tags = {camera: img.attrs for camera, img in self.images.items()}
86
+ if as_physical_unit or target_dtype:
87
+ for camera, img in self.images.items():
88
+ # Move from normalized full scale back to original data float values.
89
+ # pop() since values not true anymore after denormalization.
90
+ norm_img = image_to_original_range(
91
+ img,
92
+ self.data_tags[camera].pop("data_offset"),
93
+ self.data_tags[camera].pop("data_scale"),
94
+ )
95
+ self.images[camera] = norm_img
96
+
97
+ if target_dtype:
98
+ # For algorithm needs, cast and normalize to a specific dtype range
99
+ # NOTE: This may remove data precision e.g. uint16 -> uint8
100
+ norm_img, offset, scale = image_to_dtype_range(img, target_dtype)
101
+ self.data_tags[camera]["data_offset"] = offset
102
+ self.data_tags[camera]["data_scale"] = scale
103
+
104
+ def __getitem__(self, camera: str) -> xarray.DataArray:
105
+ """Return the datarray for the chosen camera."""
106
+ return self.images[camera]
107
+
108
+ def keys(self) -> list[str]:
109
+ """Easy access to the camera keys."""
110
+ return list(self.images.keys())
111
+
112
+ def _get_data_from_sidecar(
113
+ self, sidecar_path: Path, target_ureg: UnitRegistry | None = None
114
+ ) -> MetadataLevel0:
115
+ """Read product metadata from the sidecar file attached with the product
116
+
117
+ Parameters
118
+ ----------
119
+ sidecar_path
120
+ Path to sidecar JSON
121
+ target_ureg, optional
122
+ Unit registry to change to when validating JSON, by default None
123
+ (kuva-metadata ureg)
124
+
125
+ Returns
126
+ -------
127
+ The metadata object
128
+ """
129
+ with (sidecar_path).open("r") as fh:
130
+ if target_ureg is None:
131
+ metadata = MetadataLevel0.model_validate_json(
132
+ fh.read(),
133
+ context={
134
+ "image_path": sidecar_path.parent,
135
+ },
136
+ )
137
+ else:
138
+ # The Image subclass in MetadataLevel0 has an alignment graph that
139
+ # requires a specific context. Swapping UnitRegistries will also require
140
+ # serialization, requiring the extra graph path context parameter.
141
+ metadata = cast(
142
+ MetadataLevel0,
143
+ MetadataLevel0.model_validate_json_with_ureg(
144
+ fh.read(),
145
+ target_ureg,
146
+ context={
147
+ "image_path": sidecar_path.parent,
148
+ "graph_json_file_name": f"{sidecar_path.stem}_graph.json",
149
+ },
150
+ ),
151
+ )
152
+
153
+ return metadata
154
+
155
+ def _calculate_band_offsets_and_frames(self, cube: str):
156
+ bands_info = self.metadata.image.data_cubes[cube].bands
157
+
158
+ band_n_frames = [band.n_frames for band in bands_info]
159
+ band_offsets = np.cumsum(band_n_frames)
160
+
161
+ # The first offset ie 0 is missing and the last is not an offset just the
162
+ # length. Fix it.
163
+ band_offsets = band_offsets[:-1].tolist()
164
+ band_offsets.insert(0, 0)
165
+ return band_offsets, band_n_frames
166
+
167
+ def calculate_frame_offset(self, cube: str, band_id: int, frame_idx: int) -> int:
168
+ """Find the offset at which a frame lives within a cube."""
169
+ band_offsets, _ = self._calculate_band_offsets_and_frames(cube)
170
+ frame_offset = band_offsets[band_id] + frame_idx
171
+
172
+ return frame_offset
173
+
174
+ def read_frame(self, cube: str, band_id: int, frame_idx: int) -> np.ndarray:
175
+ """Extract a specific frame from a cube and band."""
176
+ frame_offset = self.calculate_frame_offset(cube, band_id, frame_idx)
177
+ return self[cube][frame_offset, :, :].to_numpy()
178
+
179
+ def read_band(self, cube: str, band_id: int) -> np.ndarray:
180
+ """Extract a specific band from a cube"""
181
+ band_offsets, band_n_frames = self._calculate_band_offsets_and_frames(cube)
182
+
183
+ # Calculate the final frame offset for this band and frame
184
+ band_offset_ll = band_offsets[band_id]
185
+ band_offset_ul = band_offset_ll + band_n_frames[band_id]
186
+ return self[cube][band_offset_ll:band_offset_ul, :, :].to_numpy()
187
+
188
+ def read_data_units(self) -> np.ndarray:
189
+ """Read unit of product and validate they match between cameras"""
190
+ units = [tags.get("data_unit") for tags in self.data_tags.values()]
191
+ if all(product_unit == units[0] for product_unit in units):
192
+ return units[0]
193
+ else:
194
+ # TODO: We should try conversion though
195
+ e_ = "Cameras have different physical units stored to them."
196
+ raise ValueError(e_)
197
+
198
+ def get_bad_pixel_mask(self, camera: str | None = None) -> xarray.Dataset:
199
+ """Get the bad pixel mask associated to each camera of the L0 product
200
+
201
+ Returns
202
+ -------
203
+ The bad pixel masks of the cameras
204
+ """
205
+ if camera is None:
206
+ e_ = "The `camera` argument must be given for L0 product bad pixel masks."
207
+ raise ValueError(e_)
208
+ bad_pixel_filename = f"{camera}_per_frame_cloud_mask.tif"
209
+ return self._read_array(self.image_path / bad_pixel_filename)
210
+
211
+ def get_cloud_mask(self, camera: str | None = None) -> xarray.Dataset:
212
+ """Get the cloud mask associated to the product.
213
+
214
+ Returns
215
+ -------
216
+ The cloud mask
217
+ """
218
+ if camera is None:
219
+ e_ = "The `camera` argument must be given for L0 product cloud masks."
220
+ raise ValueError(e_)
221
+ bad_pixel_filename = f"{camera}_per_frame_cloud_mask.tif"
222
+ return self._read_array(self.image_path / bad_pixel_filename)
223
+
224
+
225
+ def generate_level_0_metafile():
226
+ """Example function for reading a product and generating a metadata file from the
227
+ sidecar metadata objects.
228
+ """
229
+ import argparse
230
+
231
+ parser = argparse.ArgumentParser()
232
+ parser.add_argument("image_path")
233
+ args = parser.parse_args()
234
+
235
+ image_path = Path(args.image_path)
236
+
237
+ product = Level0Product(image_path)
238
+ product.generate_metadata_file()
@@ -0,0 +1,178 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ import rioxarray as rx
5
+ from kuva_metadata import MetadataLevel1AB, MetadataLevel1C
6
+ from pint import UnitRegistry
7
+ from xarray import Dataset
8
+
9
+ from .product_base import ProductBase
10
+
11
+
12
+ class Level1ABProduct(ProductBase[MetadataLevel1AB]):
13
+ """
14
+ Level 1AB products combine multiple L0 products into a band aligned product.
15
+
16
+ Changes to them are only performed at the metadata level where results may be
17
+ cached for further use.
18
+
19
+ Parameters
20
+ ----------
21
+ image_path
22
+ Path to the folder containing the L1A or L1B product
23
+ metadata, optional
24
+ Metadata if already read e.g. from a database. By default None, meaning
25
+ automatic fetching from metadata sidecar file
26
+ target_ureg, optional
27
+ Pint Unit Registry to swap to. This is only relevant when parsing data from a
28
+ JSON file, which by default uses the kuva-metadata ureg.
29
+
30
+ Attributes
31
+ ----------
32
+ image_path: Path
33
+ Path to the folder containing the image.
34
+ metadata: MetadataLevel1AB
35
+ The metadata associated with the images
36
+ image: xarray.DataArray
37
+ The arrays with the actual data. This have the rioxarray extension activated on
38
+ them so lots of GIS functionality are available on them. For example, the GCPs
39
+ if any could be retrieved like so: `ds.rio.get_gcps()`
40
+ data_tags: dict
41
+ Tags saved along with the product. The tag "data_unit" shows what the unit of
42
+ the product actually is.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ image_path: Path,
48
+ metadata: MetadataLevel1AB | None = None,
49
+ target_ureg: UnitRegistry | None = None,
50
+ ) -> None:
51
+ super().__init__(image_path, metadata, target_ureg)
52
+
53
+ self.image = cast(
54
+ Dataset,
55
+ rx.open_rasterio(self.image_path / "L1B.tif"),
56
+ )
57
+ self.data_tags = self.image.attrs
58
+
59
+ def _get_data_from_sidecar(
60
+ self, sidecar_path: Path, target_ureg: UnitRegistry | None = None
61
+ ) -> MetadataLevel1AB:
62
+ """Read product metadata from the sidecar file attached with the product
63
+
64
+ Parameters
65
+ ----------
66
+ sidecar_path
67
+ Path to sidecar JSON
68
+ target_ureg, optional
69
+ Unit registry to change to when validating JSON, by default None
70
+ (kuva-metadata ureg)
71
+
72
+ Returns
73
+ -------
74
+ The metadata object
75
+ """
76
+ with (sidecar_path).open("r") as fh:
77
+ if target_ureg is None:
78
+ metadata = MetadataLevel1AB.model_validate_json(fh.read())
79
+ else:
80
+ metadata = cast(
81
+ MetadataLevel1AB,
82
+ MetadataLevel1AB.model_validate_json_with_ureg(
83
+ fh.read(), target_ureg
84
+ ),
85
+ )
86
+
87
+ return metadata
88
+
89
+
90
+ class Level1CProduct(ProductBase[MetadataLevel1C]):
91
+ """
92
+ Level 1C products are georeferenced and orthorectified L1AB products.
93
+
94
+ Parameters
95
+ ----------
96
+ image_path
97
+ Path to the folder containing the L1C product
98
+ metadata, optional
99
+ Metadata if already read e.g. from a database. By default None, meaning
100
+ automatic fetching from metadata sidecar file
101
+ target_ureg, optional
102
+ Pint Unit Registry to swap to. This is only relevant when parsing data from a
103
+ JSON file, which by default uses the kuva-metadata ureg.
104
+
105
+ Attributes
106
+ ----------
107
+ image_path: Path
108
+ Path to the folder containing the image.
109
+ metadata: MetadataLevel1C
110
+ The metadata associated with the images
111
+ image: xarray.DataArray
112
+ The arrays with the actual data. This have the rioxarray extension activated on
113
+ them so lots of GIS functionality are available on them. For example, the GCPs
114
+ if any could be retrieved like so: `ds.rio.get_gcps()`
115
+ data_tags: dict
116
+ Tags saved along with the product. The tag "data_unit" shows what the unit of
117
+ the product actually is.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ image_path: Path,
123
+ metadata: MetadataLevel1C | None = None,
124
+ target_ureg: UnitRegistry | None = None,
125
+ ) -> None:
126
+ super().__init__(image_path, metadata, target_ureg)
127
+
128
+ self.image = cast(
129
+ Dataset,
130
+ rx.open_rasterio(self.image_path / "L1C.tif"),
131
+ )
132
+ self.data_tags = self.image.attrs
133
+
134
+ def _get_data_from_sidecar(
135
+ self, sidecar_path: Path, target_ureg: UnitRegistry | None = None
136
+ ) -> MetadataLevel1C:
137
+ """Read product metadata from the sidecar file attached with the product
138
+
139
+ Parameters
140
+ ----------
141
+ sidecar_path
142
+ Path to sidecar JSON
143
+ target_ureg, optional
144
+ Unit registry to change to when validating JSON, by default None
145
+ (kuva-metadata ureg)
146
+
147
+ Returns
148
+ -------
149
+ The metadata object
150
+ """
151
+ with (sidecar_path).open("r") as fh:
152
+ if target_ureg is None:
153
+ metadata = MetadataLevel1C.model_validate_json(fh.read())
154
+ else:
155
+ metadata = cast(
156
+ MetadataLevel1C,
157
+ MetadataLevel1C.model_validate_json_with_ureg(
158
+ fh.read(), target_ureg
159
+ ),
160
+ )
161
+
162
+ return metadata
163
+
164
+
165
+ def generate_level_1_metafile():
166
+ """Example function for reading a product and generating a metadata file from the
167
+ sidecar metadata objects.
168
+ """
169
+ import argparse
170
+
171
+ parser = argparse.ArgumentParser()
172
+ parser.add_argument("image_path")
173
+ args = parser.parse_args()
174
+
175
+ image_path = Path(args.image_path)
176
+
177
+ product = Level1ABProduct(image_path)
178
+ product.generate_metadata_file()
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ import rioxarray as rx
5
+ from kuva_metadata import MetadataLevel2A
6
+ from pint import UnitRegistry
7
+ from xarray import Dataset
8
+
9
+ from .product_base import ProductBase
10
+
11
+
12
+ class Level2AProduct(ProductBase[MetadataLevel2A]):
13
+ """
14
+ Level 2A products contain the atmospherically corrected BOA reflectance values.
15
+
16
+ Parameters
17
+ ----------
18
+ image_path
19
+ Path to the folder containing the L2A product
20
+ metadata, optional
21
+ Metadata if already read e.g. from a database. By default None, meaning
22
+ automatic fetching from metadata sidecar file
23
+ target_ureg, optional
24
+ Pint Unit Registry to swap to. This is only relevant when parsing data from a
25
+ JSON file, which by default uses the kuva-metadata ureg.
26
+
27
+ Attributes
28
+ ----------
29
+ image_path: Path
30
+ Path to the folder containing the image.
31
+ metadata: MetadataLevel2A
32
+ The metadata associated with the images
33
+ image: xarray.DataArray
34
+ The arrays with the actual data. This have the rioxarray extension activated on
35
+ them so lots of GIS functionality are available on them. For example, the GCPs
36
+ if any could be retrieved like so: `ds.rio.get_gcps()`
37
+ data_tags: dict
38
+ Tags saved along with the product. The tag "data_unit" shows what the unit of
39
+ the product actually is.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ image_path: Path,
45
+ metadata: MetadataLevel2A | None = None,
46
+ target_ureg: UnitRegistry | None = None,
47
+ ) -> None:
48
+ super().__init__(image_path, metadata, target_ureg)
49
+
50
+ self.image = cast(
51
+ Dataset,
52
+ rx.open_rasterio(self.image_path / "L2A.tif"),
53
+ )
54
+ self.data_tags = self.image.attrs
55
+
56
+ def _get_data_from_sidecar(
57
+ self, sidecar_path: Path, target_ureg: UnitRegistry | None = None
58
+ ) -> MetadataLevel2A:
59
+ """Read product metadata from the sidecar file attached with the product
60
+
61
+ Parameters
62
+ ----------
63
+ sidecar_path
64
+ Path to sidecar JSON
65
+ target_ureg, optional
66
+ Unit registry to change to when validating JSON, by default None
67
+ (kuva-metadata ureg)
68
+
69
+ Returns
70
+ -------
71
+ The metadata object
72
+ """
73
+ with (sidecar_path).open("r") as fh:
74
+ if target_ureg is None:
75
+ metadata = MetadataLevel2A.model_validate_json(fh.read())
76
+ else:
77
+ metadata = cast(
78
+ MetadataLevel2A,
79
+ MetadataLevel2A.model_validate_json_with_ureg(
80
+ fh.read(), target_ureg
81
+ ),
82
+ )
83
+
84
+ return metadata
85
+
86
+
87
+ def generate_level_2_metafile():
88
+ """Example function for reading a product and generating a metadata file from the
89
+ sidecar metadata objects.
90
+ """
91
+ import argparse
92
+
93
+ parser = argparse.ArgumentParser()
94
+ parser.add_argument("image_path")
95
+ args = parser.parse_args()
96
+
97
+ image_path = Path(args.image_path)
98
+
99
+ product = Level2AProduct(image_path)
100
+ product.generate_metadata_file()
@@ -0,0 +1,129 @@
1
+ from abc import ABCMeta, abstractmethod
2
+ from pathlib import Path
3
+ from typing import Generic, TypeVar, cast
4
+
5
+ import rioxarray as rx
6
+ from kuva_metadata.sections_common import MetadataBase
7
+ from pint import UnitRegistry
8
+ from pydantic import BaseModel
9
+ from xarray import Dataset
10
+
11
+ TMetadata = TypeVar("TMetadata", bound=BaseModel)
12
+
13
+
14
+ class ProductBase(Generic[TMetadata], metaclass=ABCMeta):
15
+ """Base class for all Kuva product levels containing the image and all metadata
16
+
17
+ Parameters
18
+ ----------
19
+ image_path
20
+ Local path to the stored image
21
+ metadata, optional
22
+ Metadata if already read e.g. from a database. By default None, meaning
23
+ automatic fetching from metadata sidecar file
24
+ target_ureg, optional
25
+ Pint Unit Registry to swap to. This is only relevant when parsing data from a
26
+ JSON file, which by default uses the kuva-metadata ureg.
27
+
28
+ Raises
29
+ ------
30
+ ValueError
31
+ Providing Kuva image as something else than a folder
32
+ Exception
33
+ Any errors coming from the reading of the sidecar object
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ image_path: Path,
39
+ metadata: MetadataBase | None = None,
40
+ target_ureg: UnitRegistry | None = None,
41
+ ):
42
+ self.image_path = Path(image_path)
43
+
44
+ if not self.image_path.exists():
45
+ e_ = f"Image path does not exist: {self.image_path}"
46
+ raise ValueError(e_)
47
+
48
+ if not self.image_path.is_dir():
49
+ e_ = "Kuva images are folders."
50
+ raise ValueError(e_)
51
+
52
+ if metadata is None:
53
+ sidecar_path = self.image_path / f"{self.image_path.name}.json"
54
+ try:
55
+ self.metadata = self._get_data_from_sidecar(sidecar_path, target_ureg)
56
+ except Exception as e:
57
+ e_ = f"Metadata could not be read from the sidecar: {sidecar_path}."
58
+ raise Exception(e_).with_traceback(e.__traceback__)
59
+ else:
60
+ self.metadata = metadata
61
+
62
+ @abstractmethod
63
+ def _get_data_from_sidecar(
64
+ self, sidecar_path: Path, target_ureg: UnitRegistry | None = None
65
+ ) -> TMetadata:
66
+ pass
67
+
68
+ @staticmethod
69
+ def _read_array(array_path: Path) -> Dataset:
70
+ if array_path.exists():
71
+ return cast(
72
+ Dataset,
73
+ rx.open_rasterio(array_path),
74
+ )
75
+ else:
76
+ e_ = f"Product does not contain the array to be read at '{array_path}'"
77
+ raise ValueError(e_)
78
+
79
+ def get_bad_pixel_mask(self, camera: str | None = None) -> Dataset:
80
+ """Get the bad pixel mask associated to the product.
81
+
82
+ Parameters
83
+ ----------
84
+ camera
85
+ The camera to fetch the mask for. Only valid for L0 products, and is ignored
86
+ in any other level.
87
+
88
+ Returns
89
+ -------
90
+ The bad pixel mask
91
+ """
92
+ if camera is not None:
93
+ e_ = "Parameter `camera` is not supported in this product level."
94
+ raise ValueError(e_)
95
+ return self._read_array(self.image_path / "bad_pixel_mask_aggregated.tif")
96
+
97
+ def get_cloud_mask(self, camera: str | None = None) -> Dataset:
98
+ """Get the cloud mask associated to the product.
99
+
100
+ Parameters
101
+ ----------
102
+ camera
103
+ The camera to fetch the mask for. Only valid for L0 products, and is ignored
104
+ in any other level.
105
+
106
+ Returns
107
+ -------
108
+ The cloud mask
109
+ """
110
+ if camera is not None:
111
+ e_ = "Parameter `camera` is not supported in this product level."
112
+ raise ValueError(e_)
113
+ return self._read_array(self.image_path / "cloud_mask.tif")
114
+
115
+ def generate_metadata_file(self) -> None:
116
+ """Write the sidecar files next to the product."""
117
+ metadata_file_name = self.image_path.name + ".json"
118
+ graph_json_file_name = self.image_path.name + "_graph.json"
119
+
120
+ with (self.image_path / metadata_file_name).open("w") as fh:
121
+ fh.write(
122
+ self.metadata.model_dump_json(
123
+ indent=2,
124
+ context={
125
+ "image_path": self.image_path,
126
+ "graph_json_file_name": graph_json_file_name,
127
+ },
128
+ )
129
+ )
File without changes
@@ -0,0 +1,51 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import rasterio
5
+
6
+
7
+ def db_conn_str():
8
+ "Prepare a connection string to connect to the DB"
9
+ test_db_params = {
10
+ "POSTGRES_USER": "postgres",
11
+ "POSTGRES_PASSWORD": "postgres",
12
+ "POSTGRES_HOST": "localhost",
13
+ "POSTGRES_DB": "hyperfield",
14
+ "POSTGRES_PORT": "5432",
15
+ }
16
+
17
+ def query_param(param):
18
+ return os.environ[param] if param in os.environ else test_db_params[param]
19
+
20
+ username = query_param("POSTGRES_USER")
21
+ password = query_param("POSTGRES_PASSWORD")
22
+ host = query_param("POSTGRES_HOST")
23
+ name = query_param("POSTGRES_DB")
24
+ port = query_param("POSTGRES_PORT")
25
+
26
+ conn_str = f"postgres://{username}:{password}@{host}:{port}/{name}?sslmode=disable"
27
+
28
+ return conn_str
29
+
30
+
31
+ def retrieve_folder_product_id(image_path: Path, product_level: str) -> str:
32
+ tif_files = Path(image_path).glob("*.tif")
33
+
34
+ potential_ids = set()
35
+ for tif in tif_files:
36
+ ds = rasterio.open(tif)
37
+ tags = ds.tags()
38
+
39
+ if "_KUVA_PRODUCT_LEVEL" in tags and "_KUVA_PRODUCT_ID" in tags:
40
+ if tags["_KUVA_PRODUCT_LEVEL"] == product_level:
41
+ # This are files of interest
42
+ potential_ids.add(tags["_KUVA_PRODUCT_ID"])
43
+
44
+ if len(potential_ids) == 0:
45
+ raise ValueError(f"The folder contains no KUVA L{product_level} products.")
46
+ elif len(potential_ids) > 1:
47
+ raise ValueError(
48
+ f"The folder contains more than one KUVA L{product_level} product."
49
+ )
50
+ else:
51
+ return list(potential_ids)[0]
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = [ "poetry-core>=1.0.0",]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [tool.poetry]
6
+ name = "kuva-reader"
7
+ version = "0.1.0"
8
+ description = "Manipulate the Kuva Space image and metadata formats"
9
+ authors = ["Guillem Ballesteros <guillem@kuvaspace.com>" , "Lennert Antson <lennert.antson@kuvaspace.com>", "Arthur Vandenhoeke <arthur.vandenhoeke@kuvaspace.com>", "Olli Eloranta <olli.eloranta@kuvaspace.com>"]
10
+ license = "MIT"
11
+
12
+ [tool.ruff]
13
+ target-version = "py310"
14
+ line-length = 88
15
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
16
+
17
+ [tool.mypy]
18
+ ignore_missing_imports = true
19
+
20
+ [tool.poetry.scripts]
21
+ make-l0-meta = "kuva_reader.reader.level0:generate_level_0_metafile"
22
+ make-l1-meta = "kuva_reader.reader.level1:generate_level_1_metafile"
23
+
24
+ [tool.poetry.dependencies]
25
+ python = ">=3.10,<=3.13"
26
+ numpy = "^1.26.4"
27
+ numpy-quaternion = "^2022.4.4"
28
+ dask = "^2023.12.1"
29
+ pint = "^0.22"
30
+ psycopg = "^3.2.3"
31
+ rasterio = "^1.4.1"
32
+ xarray = "^2022.12.0"
33
+ rioxarray = "^0.12.4"
34
+ kuva-geometry = "*"
35
+ kuva-metadata = "*"
36
+
37
+ # Temporarily can replace pypi version with relative dep if doing local development
38
+ # [tool.poetry.dependencies.kuva-geometry]
39
+ # path = "../kuva-geometry"
40
+ # develop = true
41
+
42
+ # [tool.poetry.dependencies.kuva-metadata]
43
+ # path = "../kuva-metadata"
44
+ # develop = true
45
+
46
+
47
+ [tool.ruff.lint]
48
+ select = [ "E", "F", "A", "DTZ", "NPY", "I", "ISC", "B003", "B004", "B015", "PTH", "D100", "D101", "D102", "D103", "D104", "D105", "D200", "W191", "W291", "W293", "N801", "N804", "N805", "T100", "S105", "S106", "S108", "S604", "S602", "S609", "UP003", "UP005", "UP006", "UP007", "UP008", "UP032", "UP035", "RUF001", "RUF200", "RUF013", "C901", "COM818", "RSE102", "EM101",]
49
+ exclude = [ ".direnv", ".eggs", ".git", ".mypy_cache", ".nox", ".pytype", ".ruff_cache", ".tox", ".venv", "__pypackages__", "_build", "build", "dist", "venv", "__pycache__",]
50
+
51
+ [tool.ruff.lint.mccabe]
52
+ max-complexity = 10
53
+
54
+ [tool.ruff.lint.pydocstyle]
55
+ convention = "numpy"
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "__init__.py" = [ "F401", "D104", "E402",]
59
+
60
+ [tool.poetry.group.dev.dependencies]
61
+ mypy = "^1.2.0"
62
+ pytest = "^7.4.2"
63
+ ruff = "^0.1.1"