kuva-metadata 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.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.1
2
+ Name: kuva-metadata
3
+ Version: 0.1.0
4
+ Summary: Definitions of Kuva Space product metadata
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: kuva-geometry
16
+ Requires-Dist: networkx (>=3.4.2,<4.0.0)
17
+ Requires-Dist: numpy (>=1.26.4,<2.0.0)
18
+ Requires-Dist: numpy-quaternion (>=2022.4.4,<2023.0.0)
19
+ Requires-Dist: pint (>=0.22,<0.23)
20
+ Requires-Dist: pydantic (>=2.9.2,<3.0.0)
21
+ Requires-Dist: python-dateutil (>=2.9.0.post0,<3.0.0)
22
+ Requires-Dist: pytz (>=2023.4,<2024.0)
23
+ Requires-Dist: rasterio (>=1.4.1,<2.0.0)
24
+ Requires-Dist: rich (>=13.9.3,<14.0.0)
25
+ Requires-Dist: shapely (>=2.0.6,<3.0.0)
26
+ Requires-Dist: sympy (>=1.13.3,<2.0.0)
@@ -0,0 +1,23 @@
1
+ """
2
+ This library defines the metadata format used by the sidecar files that accompany
3
+ the images produced by Hyperfield satellites.
4
+
5
+ The core library used is pydantic which among other nice things allows us to
6
+ (de)serialize (from)to JSON .
7
+
8
+ If you are interested on reading from the Hyperfield metadata database into such
9
+ objects then you want to look at the `hyperfield-db` project.
10
+ """
11
+
12
+ from .sections_common import MetadataBase
13
+ from .sections_l0 import MetadataLevel0
14
+ from .sections_l1 import MetadataLevel1AB, MetadataLevel1C
15
+ from .sections_l2 import MetadataLevel2A
16
+
17
+ __all__ = [
18
+ "MetadataBase",
19
+ "MetadataLevel0",
20
+ "MetadataLevel1AB",
21
+ "MetadataLevel1C",
22
+ "MetadataLevel2A",
23
+ ]
@@ -0,0 +1,36 @@
1
+ """Custom classes to store in Pydantic models"""
2
+
3
+ import numpy as np
4
+ from pydantic import BaseModel, ConfigDict
5
+ from rasterio.crs import CRS
6
+ from shapely import Point, Polygon, get_coordinates
7
+
8
+
9
+ class CRSGeometry(BaseModel):
10
+ """
11
+ Store a `shapely.geometry` together with the relevant CRS.
12
+
13
+ Attributes
14
+ ----------
15
+ geom: Polygon
16
+ Shapely polygon with the geometry
17
+ crs_epsg: rasterio.crs.CRS
18
+ CRS over which the polygon is represented.
19
+ """
20
+
21
+ geom: Polygon | Point
22
+ crs_epsg: CRS
23
+ model_config = ConfigDict(
24
+ validate_assignment=True,
25
+ arbitrary_types_allowed=True,
26
+ )
27
+
28
+ @property
29
+ def numpy(self) -> np.ndarray:
30
+ """Turn the geometry into a numpy array of polygon coordinates.
31
+
32
+ Notes
33
+ -----
34
+ This loses the CRS information so make sure to keep track of it elsewhere
35
+ """
36
+ return get_coordinates(self.geom, include_z=True).squeeze()
@@ -0,0 +1,173 @@
1
+ """Geometry utilities to find the footprint associated with an in orbit camera"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+ import quaternion
9
+ import shapely
10
+ from kuva_geometry import ellipsoid, geometry
11
+
12
+ if TYPE_CHECKING:
13
+ from .sections_l0 import Camera, Frame
14
+
15
+
16
+ def get_sensor_corner_rays(
17
+ camera: Camera,
18
+ position: np.ndarray,
19
+ orientation: quaternion.quaternion,
20
+ use_negative_sensor_plane: bool = False,
21
+ ):
22
+ """Get the rays emanating from a camera associated with the corners of the sensor
23
+
24
+ Parameters
25
+ ----------
26
+ camera
27
+ Camera intrinsic parameters
28
+ position
29
+ The position of the satellite in ECEF coordinates expressed in meters (3 values)
30
+ orientation
31
+ Quaternion describing the orientation of the satellite wrt ECEF
32
+ use_negative_sensor_plane, optional
33
+ Whether to use the negative sensor plane to calculate the rays, by default False
34
+
35
+ Returns
36
+ -------
37
+ The vectors that join the back nodal point to the sensor corners.
38
+ """
39
+ ray_origin, rays = geometry.get_sensor_corner_rays(
40
+ np.array(position),
41
+ orientation,
42
+ camera.focal_distance.to("meters").magnitude,
43
+ camera.width.to("meters").magnitude,
44
+ camera.height.to("meters").magnitude,
45
+ use_negative_sensor_plane,
46
+ )
47
+
48
+ return ray_origin, rays
49
+
50
+
51
+ def get_sensor_rays(
52
+ sensor_coords: np.ndarray,
53
+ camera: Camera,
54
+ position: np.ndarray,
55
+ orientation: quaternion.quaternion,
56
+ ) -> tuple[np.ndarray, np.ndarray]:
57
+ """
58
+ Produce an array with the ray vectors for a collections of sensor coords.
59
+
60
+ Sensor coords are measured from the center of the sensor and are valid within
61
+ [-0.5, 0.5]^2.
62
+
63
+ Parameters
64
+ ----------
65
+ sensor_coords
66
+ A rank-2 tensor of dimension (n_sensor_coords, 2)
67
+ camera
68
+ Camera intrinsic parameters
69
+ position
70
+ The position of the satellite in ECEF coordinates expressed in meters (3 values)
71
+ orientation
72
+ Quaternion describing the orientation of the satellite wrt ECEF
73
+
74
+ Returns
75
+ -------
76
+ The position from which the rays emanate, i.e. the camera position and rank 2 tensor
77
+ of dimensions (n_sensor_coords, 3) describing the direction vector of the rays.
78
+ """
79
+ rays = geometry.get_sensor_rays(
80
+ sensor_coords,
81
+ position,
82
+ orientation,
83
+ camera.focal_distance.to("meters").magnitude,
84
+ camera.width.to("meters").magnitude,
85
+ camera.height.to("meters").magnitude,
86
+ use_negative_sensor_plane=True,
87
+ )
88
+
89
+ return position, rays
90
+
91
+
92
+ def frame_ray_Earth_intersections(
93
+ sensor_coords: np.ndarray, frame: Frame, camera: Camera
94
+ ) -> list[shapely.Point]:
95
+ """
96
+ Return the intersecton of camera ray with the WGS84 ellipsoid.
97
+
98
+ Parameters
99
+ ----------
100
+ sensor_coords
101
+ The coordinates of the ray we are interested on. Within sensor coords range
102
+ between +- 0.5 on both axis.
103
+ frame
104
+ Information about how the frame was taken. Namely camera orientation and
105
+ position.
106
+ camera
107
+ The camera parameters
108
+
109
+ Returns
110
+ -------
111
+ Intersection of camera rays on the Earth
112
+ """
113
+ sat_pos, sat_ecef_orientation = frame.position.numpy, frame.sat_ecef_orientation
114
+
115
+ # Make sure the order is correct, i.e. begin from the right-hand side!
116
+ # The process is the same as for how we calculate homographies.
117
+ orientation = sat_ecef_orientation * camera.sensor_wrt_sat_axis_quaternion
118
+
119
+ _, rays = get_sensor_rays(sensor_coords, camera, sat_pos, orientation)
120
+
121
+ rays = rays / np.sqrt((rays**2).sum(axis=1))[:, None]
122
+
123
+ intersections = [
124
+ shapely.Point(*ellipsoid.ray_Earth_intersection(sat_pos, rays[idx]))
125
+ for idx in range(rays.shape[0])
126
+ ]
127
+
128
+ return intersections
129
+
130
+
131
+ def frame_footprint(
132
+ frame: Frame,
133
+ camera: Camera,
134
+ use_negative_sensor_plane: bool = False,
135
+ ) -> shapely.Polygon:
136
+ """Find the footprint on the ground associated with the corners rays of a camera
137
+
138
+ Parameters
139
+ ----------
140
+ frame
141
+ Information about how the frame was taken. Namely camera orientation and
142
+ position.
143
+ camera
144
+ The camera parameters
145
+ use_negative_sensor_plane, optional
146
+ Whether to use the negative sensor plane to calculate the rays, by default False
147
+
148
+ Returns
149
+ -------
150
+ The footprint on the ground as a polygon
151
+ """
152
+ sat_pos, sat_ecef_orientation = frame.position.numpy, frame.sat_ecef_orientation
153
+
154
+ orientation = sat_ecef_orientation * camera.sensor_wrt_sat_axis_quaternion
155
+
156
+ _, sensor_corners_ray = get_sensor_corner_rays(
157
+ camera, sat_pos, orientation, use_negative_sensor_plane
158
+ )
159
+
160
+ sensor_corners_ray = (
161
+ sensor_corners_ray / np.sqrt((sensor_corners_ray**2).sum(axis=1))[:, None]
162
+ )
163
+
164
+ footprint = [
165
+ ellipsoid.ray_Earth_intersection(sat_pos, sensor_corners_ray[idx])
166
+ for idx in range(sensor_corners_ray.shape[0])
167
+ ]
168
+
169
+ # Polygons are ordered X,Y despite the CRS being lat, long i.e. x,y. If you
170
+ # leave the points in the order they come in the 4326 CRS things will break.
171
+ footprint = shapely.Polygon(footprint)
172
+
173
+ return footprint
@@ -0,0 +1,13 @@
1
+ """Additional type aliases"""
2
+
3
+ import numpy as np
4
+ from pint import Quantity
5
+ from quaternion import quaternion
6
+ from shapely import Polygon
7
+
8
+ # All this types with lists would be better expressed with tuples but pydantic
9
+ # reads the json as lists so we have a type to match.
10
+ Quantity_ = Quantity | list[float | str]
11
+ quaternion_ = quaternion | list[float]
12
+ array_3x3_ = np.ndarray | list[list[float]]
13
+ Polygon_ = Polygon | str
File without changes
@@ -0,0 +1,194 @@
1
+ import typing
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import pytz
7
+ from pint import Quantity, UnitRegistry
8
+ from pydantic import (
9
+ UUID4,
10
+ BaseModel,
11
+ ConfigDict,
12
+ field_serializer,
13
+ field_validator,
14
+ )
15
+ from rasterio.rpc import RPC
16
+
17
+ from kuva_metadata.serializers import serialize_RPCs
18
+ from kuva_metadata.validators import check_is_utc_datetime, parse_rpcs, parse_timestamp
19
+
20
+ _T = typing.TypeVar("_T")
21
+
22
+
23
+ class Header(BaseModel):
24
+ """Header for the metadata files
25
+
26
+ Attributes
27
+ ----------
28
+ version
29
+ Version of the library used to create the file.
30
+ author
31
+ The author of the file.
32
+ creation_date
33
+ Creation date for the metadata file.
34
+ """
35
+
36
+ version: str
37
+ author: str
38
+ creation_date: datetime = datetime.now(tz=pytz.utc)
39
+
40
+ _parse_timestamp = field_validator("creation_date", mode="before")(parse_timestamp)
41
+ _check_tz = field_validator("creation_date")(check_is_utc_datetime)
42
+ model_config = ConfigDict(validate_assignment=True)
43
+
44
+
45
+ class Satellite(BaseModel):
46
+ """Specifies the information relating to the satellite from which the file images
47
+ where acquired.
48
+
49
+ Attributes
50
+ ----------
51
+ name
52
+ Short name of the satellite.
53
+ cospar_id
54
+ International designator assigned to the satellite after launch.
55
+ launch_date
56
+ When the satellite was launched
57
+ """
58
+
59
+ name: str
60
+ cospar_id: str
61
+ launch_date: datetime
62
+
63
+ _parse_timestamp = field_validator("launch_date", mode="before")(parse_timestamp)
64
+ _check_tz = field_validator("launch_date")(check_is_utc_datetime)
65
+ model_config = ConfigDict(validate_assignment=True)
66
+
67
+
68
+ class Radiometry(BaseModel):
69
+ """Information required for TOA calculations and physical units
70
+
71
+ Attributes
72
+ ----------
73
+ lut_file
74
+ A lookup table stored together with the image file that associates raw image
75
+ values to a radiance for different wavelengths and integration times. Stored as
76
+ a numpy `npy` file.
77
+ sun_spectrum_file
78
+ Sun spectrum radiance of each band. Required for top of atmosphere calculation.
79
+ """
80
+
81
+ lut_file: Path
82
+ sun_spectrum_file: Path
83
+
84
+
85
+ class RPCoefficients(BaseModel):
86
+ """Rational polynomial function coefficients for orthorectification.
87
+
88
+ A rational polynomial functions is simply a function which is the ratio of two
89
+ polynomials. In our case we have two functions that are R^3 -> R^2 and map world
90
+ coordinates to pixel space. The first function maps the x coordinates and the
91
+ second the y coordinates.
92
+
93
+ Attributes
94
+ ----------
95
+ rpcs
96
+ Rational polynomial function coefficients for orthorectification
97
+ """
98
+
99
+ rpcs: RPC
100
+
101
+ _parse_rpcs = field_validator("rpcs", mode="before")(parse_rpcs)
102
+
103
+ @field_serializer("rpcs")
104
+ def _serialize_RPCs(self, rpcs: RPC):
105
+ return serialize_RPCs(rpcs)
106
+
107
+ model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
108
+
109
+
110
+ class BaseModelWithUnits(BaseModel, typing.Generic[_T]):
111
+ """Allows an pint unit registry to be plugged in one of the classes using units"""
112
+
113
+ @classmethod
114
+ def model_validate_json_with_ureg(
115
+ cls, json_data: str, new_ureg: UnitRegistry, **val_kwargs
116
+ ) -> _T:
117
+ """Will create a model from JSON data. However, the data will be copied so that
118
+ each Quantity in all submodels is recursively converted to a new given
119
+ UnitRegistry. If data is read from a JSON file without this method, it will be
120
+ attached to the kuva-metadata default UnitRegistry.
121
+
122
+ Parameters
123
+ ----------
124
+ json_data
125
+ Model data to validate
126
+ new_ureg
127
+ Pint UnitRegistry to swap to
128
+
129
+ Returns
130
+ -------
131
+ The validated model instance
132
+ """
133
+ model_instance = cls.model_validate_json(json_data, **val_kwargs)
134
+ swapped_instance = cast(
135
+ _T, swap_ureg_in_instance(model_instance, new_ureg, **val_kwargs)
136
+ )
137
+
138
+ return swapped_instance
139
+
140
+
141
+ class MetadataBase(BaseModelWithUnits):
142
+ """Base class for all product levels' metadata
143
+
144
+ Attributes
145
+ ----------
146
+ id
147
+ Metadata ID for identifying metadata from DB
148
+ header
149
+ Metadata file header
150
+ satellite
151
+ Satellite the metadata's product has been created for
152
+ image
153
+ Image that the metadata is associated to
154
+ """
155
+
156
+ id: UUID4
157
+ header: Header
158
+ satellite: Satellite
159
+
160
+
161
+ def swap_ureg_in_instance(obj: BaseModel, new_ureg: UnitRegistry, **val_kwargs):
162
+ """Swaps Pint UnitRegistry recursively within a pydantic model.
163
+
164
+ Parameters
165
+ ----------
166
+ obj
167
+ Instance of a model
168
+ new_ureg
169
+ Pint UnitRegistry to swap to
170
+ val_kwargs
171
+ Keyword arguments that are required in model validation, e.g. a pydantic context
172
+
173
+ Returns
174
+ -------
175
+ The validated model instance which now has the new UnitRegistry in its or its
176
+ child objects' Quantities
177
+ """
178
+
179
+ def _replace_ureg(value):
180
+ """Helper recursion function to correctly go through the different attributes"""
181
+ if isinstance(value, Quantity):
182
+ return new_ureg.Quantity(value.magnitude, value.units)
183
+ elif isinstance(value, BaseModel):
184
+ return swap_ureg_in_instance(value, new_ureg, **val_kwargs)
185
+ elif isinstance(value, (list, tuple, set)):
186
+ return type(value)(_replace_ureg(v) for v in value)
187
+ elif isinstance(value, dict):
188
+ return {k: _replace_ureg(v) for k, v in value.items()}
189
+ else:
190
+ return value
191
+
192
+ field_values = obj.model_dump(**val_kwargs)
193
+ updated_field_values = _replace_ureg(field_values)
194
+ return obj.__class__.model_validate(updated_field_values, **val_kwargs)