cesiumjs-anywidget 0.6.0__py3-none-any.whl → 0.7.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.
@@ -1,6 +1,27 @@
1
1
  """CesiumJS Anywidget - A Jupyter widget for CesiumJS 3D globe visualization."""
2
2
 
3
3
  from .widget import CesiumWidget
4
+ from .geoid import (
5
+ get_geoid_undulation,
6
+ msl_to_wgs84,
7
+ wgs84_to_msl,
8
+ clear_geoid_cache,
9
+ set_geoid_data_url,
10
+ )
11
+ from .logger import get_logger, set_log_level
12
+ from .exif_utils import extract_all_metadata, extract_gps_data, extract_datetime
4
13
 
5
- __version__ = "0.1.0"
6
- __all__ = ["CesiumWidget"]
14
+ __version__ = "0.6.0"
15
+ __all__ = [
16
+ "CesiumWidget",
17
+ "get_geoid_undulation",
18
+ "msl_to_wgs84",
19
+ "wgs84_to_msl",
20
+ "clear_geoid_cache",
21
+ "set_geoid_data_url",
22
+ "get_logger",
23
+ "set_log_level",
24
+ "extract_all_metadata",
25
+ "extract_gps_data",
26
+ "extract_datetime",
27
+ ]
@@ -0,0 +1,281 @@
1
+ """EXIF data extraction utilities for photo geolocation."""
2
+
3
+ import exifread
4
+ from datetime import datetime
5
+ from typing import Optional, Dict, Any, Tuple
6
+ from pathlib import Path
7
+ from .logger import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def _convert_to_degrees(value) -> float:
13
+ """Convert GPS coordinates to degrees.
14
+
15
+ Parameters
16
+ ----------
17
+ value : exifread.utils.Ratio list
18
+ GPS coordinate in degrees, minutes, seconds format
19
+
20
+ Returns
21
+ -------
22
+ float
23
+ Coordinate in decimal degrees
24
+ """
25
+ d = float(value.values[0].num) / float(value.values[0].den)
26
+ m = float(value.values[1].num) / float(value.values[1].den)
27
+ s = float(value.values[2].num) / float(value.values[2].den)
28
+
29
+ # Normalize malformed DMS values from some writers
30
+ # Some encoders store seconds as total arc-seconds * 60 (e.g., 1425 instead of 23.75)
31
+ if s >= 60 and m < 60:
32
+ s = s / 60.0
33
+
34
+ if s >= 60:
35
+ carry = int(s // 60)
36
+ s = s - (carry * 60)
37
+ m += carry
38
+
39
+ if m >= 60:
40
+ carry = int(m // 60)
41
+ m = m - (carry * 60)
42
+ d += carry
43
+
44
+ return d + (m / 60.0) + (s / 3600.0)
45
+
46
+
47
+ def extract_gps_data(image_path: str) -> Optional[Dict[str, float]]:
48
+ """Extract GPS coordinates from image EXIF data.
49
+
50
+ Parameters
51
+ ----------
52
+ image_path : str
53
+ Path to the image file
54
+
55
+ Returns
56
+ -------
57
+ dict or None
58
+ Dictionary containing 'latitude', 'longitude', and optionally 'altitude'
59
+ Returns None if GPS data is not available
60
+
61
+ Examples
62
+ --------
63
+ >>> gps_data = extract_gps_data('photo.jpg')
64
+ >>> if gps_data:
65
+ ... print(f"Location: {gps_data['latitude']}, {gps_data['longitude']}")
66
+ """
67
+ try:
68
+ with open(image_path, 'rb') as f:
69
+ tags = exifread.process_file(f, details=False)
70
+
71
+ # Check if GPS data exists
72
+ if 'GPS GPSLatitude' not in tags or 'GPS GPSLongitude' not in tags:
73
+ logger.warning(f"No GPS data found in {image_path}")
74
+ return None
75
+
76
+ # Extract latitude
77
+ lat = _convert_to_degrees(tags['GPS GPSLatitude'])
78
+ if tags.get('GPS GPSLatitudeRef', 'N').values[0] == 'S':
79
+ lat = -lat
80
+
81
+ # Extract longitude
82
+ lon = _convert_to_degrees(tags['GPS GPSLongitude'])
83
+ if tags.get('GPS GPSLongitudeRef', 'E').values[0] == 'W':
84
+ lon = -lon
85
+
86
+ result = {
87
+ 'latitude': lat,
88
+ 'longitude': lon,
89
+ }
90
+
91
+ # Extract altitude if available
92
+ if 'GPS GPSAltitude' in tags:
93
+ alt_tag = tags['GPS GPSAltitude']
94
+ altitude = float(alt_tag.values[0].num) / float(alt_tag.values[0].den)
95
+
96
+ # Check altitude reference (0 = above sea level, 1 = below sea level)
97
+ if 'GPS GPSAltitudeRef' in tags:
98
+ alt_ref = tags['GPS GPSAltitudeRef'].values[0]
99
+ if alt_ref == 1:
100
+ altitude = -altitude
101
+
102
+ result['altitude'] = altitude
103
+
104
+ logger.info(f"Extracted GPS data from {image_path}: {result}")
105
+ return result
106
+
107
+ except Exception as e:
108
+ logger.error(f"Error extracting GPS data from {image_path}: {e}")
109
+ return None
110
+
111
+
112
+ def extract_datetime(image_path: str) -> Optional[datetime]:
113
+ """Extract capture datetime from image EXIF data.
114
+
115
+ Parameters
116
+ ----------
117
+ image_path : str
118
+ Path to the image file
119
+
120
+ Returns
121
+ -------
122
+ datetime or None
123
+ Image capture datetime, or None if not available
124
+
125
+ Examples
126
+ --------
127
+ >>> dt = extract_datetime('photo.jpg')
128
+ >>> if dt:
129
+ ... print(f"Captured on: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
130
+ """
131
+ try:
132
+ with open(image_path, 'rb') as f:
133
+ tags = exifread.process_file(f, details=False)
134
+
135
+ # Try different datetime tags
136
+ for tag_name in ['EXIF DateTimeOriginal', 'EXIF DateTime', 'Image DateTime']:
137
+ if tag_name in tags:
138
+ dt_str = str(tags[tag_name])
139
+ # Parse datetime (format: "YYYY:MM:DD HH:MM:SS")
140
+ dt = datetime.strptime(dt_str, '%Y:%m:%d %H:%M:%S')
141
+ logger.info(f"Extracted datetime from {image_path}: {dt}")
142
+ return dt
143
+
144
+ logger.warning(f"No datetime found in {image_path}")
145
+ return None
146
+
147
+ except Exception as e:
148
+ logger.error(f"Error extracting datetime from {image_path}: {e}")
149
+ return None
150
+
151
+
152
+ def extract_camera_info(image_path: str) -> Dict[str, Any]:
153
+ """Extract camera information from image EXIF data.
154
+
155
+ Parameters
156
+ ----------
157
+ image_path : str
158
+ Path to the image file
159
+
160
+ Returns
161
+ -------
162
+ dict
163
+ Dictionary containing camera make, model, focal length, sensor size, etc.
164
+
165
+ Examples
166
+ --------
167
+ >>> info = extract_camera_info('photo.jpg')
168
+ >>> print(f"Camera: {info.get('make')} {info.get('model')}")
169
+ """
170
+ result = {}
171
+
172
+ try:
173
+ with open(image_path, 'rb') as f:
174
+ tags = exifread.process_file(f, details=False)
175
+
176
+ # Camera make and model
177
+ if 'Image Make' in tags:
178
+ result['make'] = str(tags['Image Make'])
179
+ if 'Image Model' in tags:
180
+ result['model'] = str(tags['Image Model'])
181
+
182
+ # Focal length
183
+ if 'EXIF FocalLength' in tags:
184
+ focal_tag = tags['EXIF FocalLength']
185
+ focal_length = float(focal_tag.values[0].num) / float(focal_tag.values[0].den)
186
+ result['focal_length_mm'] = focal_length
187
+
188
+ # Focal length in 35mm equivalent
189
+ if 'EXIF FocalLengthIn35mmFilm' in tags:
190
+ result['focal_length_35mm'] = int(tags['EXIF FocalLengthIn35mmFilm'].values[0])
191
+
192
+ # Image dimensions
193
+ if 'EXIF ExifImageWidth' in tags:
194
+ result['image_width'] = int(tags['EXIF ExifImageWidth'].values[0])
195
+ if 'EXIF ExifImageLength' in tags:
196
+ result['image_height'] = int(tags['EXIF ExifImageLength'].values[0])
197
+
198
+ # Orientation
199
+ if 'Image Orientation' in tags:
200
+ result['orientation'] = str(tags['Image Orientation'])
201
+
202
+ logger.info(f"Extracted camera info from {image_path}: {result}")
203
+ return result
204
+
205
+ except Exception as e:
206
+ logger.error(f"Error extracting camera info from {image_path}: {e}")
207
+ return result
208
+
209
+
210
+ def get_image_dimensions(image_path: str) -> Optional[Tuple[int, int]]:
211
+ """Get image dimensions (width, height) from file.
212
+
213
+ Parameters
214
+ ----------
215
+ image_path : str
216
+ Path to the image file
217
+
218
+ Returns
219
+ -------
220
+ tuple or None
221
+ (width, height) in pixels, or None if unable to read
222
+ """
223
+ try:
224
+ from PIL import Image
225
+ with Image.open(image_path) as img:
226
+ return img.size
227
+ except Exception as e:
228
+ logger.error(f"Error reading image dimensions from {image_path}: {e}")
229
+ return None
230
+
231
+
232
+ def extract_all_metadata(image_path: str) -> Dict[str, Any]:
233
+ """Extract all relevant metadata from an image.
234
+
235
+ Combines GPS, datetime, camera info, and image dimensions into one dictionary.
236
+
237
+ Parameters
238
+ ----------
239
+ image_path : str
240
+ Path to the image file
241
+
242
+ Returns
243
+ -------
244
+ dict
245
+ Dictionary containing all extracted metadata
246
+
247
+ Examples
248
+ --------
249
+ >>> metadata = extract_all_metadata('photo.jpg')
250
+ >>> print(metadata)
251
+ {'latitude': 48.8566, 'longitude': 2.3522, 'altitude': 100,
252
+ 'datetime': datetime(2024, 1, 15, 14, 30, 0),
253
+ 'make': 'Canon', 'model': 'EOS R5', ...}
254
+ """
255
+ metadata = {
256
+ 'file_path': str(Path(image_path).absolute()),
257
+ 'file_name': Path(image_path).name,
258
+ }
259
+
260
+ # GPS data
261
+ gps_data = extract_gps_data(image_path)
262
+ if gps_data:
263
+ metadata.update(gps_data)
264
+
265
+ # Datetime
266
+ dt = extract_datetime(image_path)
267
+ if dt:
268
+ metadata['datetime'] = dt
269
+ metadata['datetime_str'] = dt.isoformat()
270
+
271
+ # Camera info
272
+ camera_info = extract_camera_info(image_path)
273
+ metadata.update(camera_info)
274
+
275
+ # Image dimensions
276
+ dims = get_image_dimensions(image_path)
277
+ if dims:
278
+ metadata['width'] = dims[0]
279
+ metadata['height'] = dims[1]
280
+
281
+ return metadata
@@ -0,0 +1,299 @@
1
+ """Geoid conversion utilities for MSL to WGS84 altitude conversion.
2
+
3
+ This module provides functions to convert between Mean Sea Level (MSL) altitudes
4
+ and WGS84 ellipsoid heights using the EGM96 geoid model.
5
+
6
+ The EGM96 15-minute grid data is automatically downloaded from the internet on
7
+ first use and cached locally. You can customize the data source URL if needed.
8
+ """
9
+
10
+ import os
11
+ import tarfile
12
+ import urllib.request
13
+ import zipfile
14
+ from functools import lru_cache
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from .logger import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ # Default URL for EGM96 15-minute grid data
24
+ # This is the official NGA download link
25
+ DEFAULT_EGM96_URL = "https://earth-info.nga.mil/php/download.php?file=egm-96interpolation"
26
+
27
+ # User-configurable URL (can be changed before first use)
28
+ _EGM96_DATA_URL: Optional[str] = None
29
+
30
+ # Cached geoid model
31
+ _geoid_model = None
32
+
33
+ # Cache directory for downloaded geoid files
34
+ _CACHE_DIR = Path.home() / ".cache" / "cesiumjs_anywidget" / "geoid"
35
+
36
+
37
+ def set_geoid_data_url(url: str) -> None:
38
+ """Set a custom URL for downloading EGM96 geoid data.
39
+
40
+ This must be called before the first use of geoid functions.
41
+
42
+ Parameters
43
+ ----------
44
+ url : str
45
+ URL to download EGM96 grid data (tar.bz2 or direct grid file)
46
+
47
+ Examples
48
+ --------
49
+ >>> from cesiumjs_anywidget.geoid import set_geoid_data_url
50
+ >>> set_geoid_data_url("https://example.com/egm96-15.tar.bz2")
51
+ """
52
+ global _EGM96_DATA_URL, _geoid_model
53
+ _EGM96_DATA_URL = url
54
+ # Clear cached model to force reload with new URL
55
+ _geoid_model = None
56
+ get_geoid_undulation.cache_clear()
57
+
58
+
59
+ def _download_egm96_grid(url: str, cache_dir: Path) -> Path:
60
+ """Download and extract the EGM96 grid file from an archive (zip or tar.bz2).
61
+
62
+ Parameters
63
+ ----------
64
+ url : str
65
+ URL to download the archive from
66
+ cache_dir : Path
67
+ Directory to store the extracted grid file
68
+
69
+ Returns
70
+ -------
71
+ Path
72
+ Path to the extracted .GRD file
73
+
74
+ Raises
75
+ ------
76
+ FileNotFoundError
77
+ If no .GRD file found in archive
78
+ Exception
79
+ If download or extraction fails
80
+ """
81
+ cache_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Expected output file (WW15MGH.GRD is the standard EGM96 15-minute grid)
84
+ grd_file = cache_dir / "WW15MGH.GRD"
85
+
86
+ if grd_file.exists():
87
+ return grd_file
88
+
89
+ # Download the archive
90
+ archive_path = cache_dir / "egm96-data.archive"
91
+
92
+ if not archive_path.exists():
93
+ logger.info("Downloading EGM96 grid data from %s...", url)
94
+ urllib.request.urlretrieve(url, archive_path)
95
+ logger.info("Download complete: %s", archive_path)
96
+
97
+ # Detect archive type and extract
98
+ logger.info("Extracting %s...", archive_path)
99
+
100
+ try:
101
+ # Try ZIP first
102
+ with zipfile.ZipFile(archive_path, 'r') as zip_ref:
103
+ # Find the .GRD file in the archive
104
+ grd_members = [name for name in zip_ref.namelist()
105
+ if name.endswith('.GRD') or name.endswith('.grd')]
106
+
107
+ if not grd_members:
108
+ raise FileNotFoundError(f"No .GRD file found in ZIP archive {archive_path}")
109
+
110
+ # Extract the first .GRD file found
111
+ member_name = grd_members[0]
112
+ zip_ref.extract(member_name, cache_dir)
113
+
114
+ # Rename to standard name if needed
115
+ extracted_path = cache_dir / member_name
116
+ if extracted_path.name != grd_file.name:
117
+ extracted_path.rename(grd_file)
118
+
119
+ except zipfile.BadZipFile:
120
+ # Try tar.bz2
121
+ with tarfile.open(archive_path, "r:bz2") as tar:
122
+ # Find the .GRD file in the archive
123
+ grd_members = [m for m in tar.getmembers()
124
+ if m.name.endswith('.GRD') or m.name.endswith('.grd')]
125
+
126
+ if not grd_members:
127
+ raise FileNotFoundError(f"No .GRD file found in tar.bz2 archive {archive_path}")
128
+
129
+ # Extract the first .GRD file found
130
+ member = grd_members[0]
131
+ tar.extract(member, cache_dir)
132
+
133
+ # Rename to standard name if needed
134
+ extracted_path = cache_dir / member.name
135
+ if extracted_path.name != grd_file.name:
136
+ extracted_path.rename(grd_file)
137
+
138
+ logger.info("Extraction complete: %s", grd_file)
139
+
140
+ # Clean up archive if extraction successful
141
+ if archive_path.exists() and grd_file.exists():
142
+ archive_path.unlink()
143
+
144
+ return grd_file
145
+
146
+
147
+ def _get_geoid_model():
148
+ """Get or create the cached EGM96 geoid model.
149
+
150
+ The model is loaded from GeoidEGM96 using a .GRD grid file.
151
+ The grid file is automatically downloaded on first use and cached locally.
152
+
153
+ Returns
154
+ -------
155
+ GeoidEGM96
156
+ The cached geoid model instance
157
+ """
158
+ global _geoid_model
159
+
160
+ if _geoid_model is None:
161
+ from pygeodesy.geoids import GeoidEGM96
162
+ from pygeodesy.datums import Datums
163
+
164
+ # Use custom URL if set, otherwise use default
165
+ url = _EGM96_DATA_URL if _EGM96_DATA_URL else DEFAULT_EGM96_URL
166
+
167
+ # Download and extract the grid file if needed
168
+ grd_file = _download_egm96_grid(url, _CACHE_DIR)
169
+
170
+ # Create the GeoidEGM96 model with the grid file path
171
+ _geoid_model = GeoidEGM96(str(grd_file), datum=Datums.WGS84)
172
+
173
+ return _geoid_model
174
+
175
+
176
+ @lru_cache(maxsize=1000)
177
+ def get_geoid_undulation(latitude: float, longitude: float) -> float:
178
+ """Calculate the geoid undulation at a given location using EGM96.
179
+
180
+ The geoid undulation (also called geoid height or geoid separation) is the
181
+ height of the geoid above the WGS84 reference ellipsoid. This value is
182
+ needed to convert between GPS/MSL altitudes and WGS84 ellipsoid heights.
183
+
184
+ On first use, the EGM96 15-minute grid data will be automatically downloaded
185
+ and cached locally. Subsequent calls will use the cached data.
186
+
187
+ Parameters
188
+ ----------
189
+ latitude : float
190
+ Latitude in degrees (-90 to 90)
191
+ longitude : float
192
+ Longitude in degrees (-180 to 180 or 0 to 360)
193
+
194
+ Returns
195
+ -------
196
+ float
197
+ Geoid undulation in meters. Positive values indicate the geoid is
198
+ above the WGS84 ellipsoid at this location.
199
+
200
+ Examples
201
+ --------
202
+ >>> # France (46°N, 4°E) - geoid is about 47m above ellipsoid
203
+ >>> undulation = get_geoid_undulation(46.0, 4.0)
204
+ >>> 45 < undulation < 50
205
+ True
206
+
207
+ Notes
208
+ -----
209
+ The EGM96 model provides approximately ±0.5 to ±1 meter accuracy globally.
210
+ The grid data is downloaded automatically from GeographicLib on first use.
211
+ """
212
+ geoid = _get_geoid_model()
213
+
214
+ # GeoidEGM96.height() accepts lat, lon directly
215
+ return geoid.height(latitude, longitude)
216
+
217
+
218
+
219
+ def msl_to_wgs84(altitude_msl: float, latitude: float, longitude: float) -> float:
220
+ """Convert Mean Sea Level (MSL) altitude to WGS84 ellipsoid height.
221
+
222
+ GPS receivers typically report altitude above MSL (geoid), but CZML and
223
+ Cesium use WGS84 ellipsoid heights. This function performs the conversion:
224
+
225
+ WGS84_height = altitude_msl + geoid_undulation
226
+
227
+ Parameters
228
+ ----------
229
+ altitude_msl : float
230
+ Altitude above Mean Sea Level in meters
231
+ latitude : float
232
+ Latitude in degrees (-90 to 90)
233
+ longitude : float
234
+ Longitude in degrees (-180 to 180 or 0 to 360)
235
+
236
+ Returns
237
+ -------
238
+ float
239
+ Height above WGS84 ellipsoid in meters
240
+
241
+ Examples
242
+ --------
243
+ >>> # Sea level in France would be ~47m above WGS84 ellipsoid
244
+ >>> wgs84_height = msl_to_wgs84(0, 46.0, 4.0)
245
+ >>> 45 < wgs84_height < 50
246
+ True
247
+
248
+ >>> # Mountain at 1000m MSL
249
+ >>> wgs84_height = msl_to_wgs84(1000, 46.0, 4.0)
250
+ >>> 1045 < wgs84_height < 1050
251
+ True
252
+ """
253
+ geoid_undulation = get_geoid_undulation(latitude, longitude)
254
+ return altitude_msl + geoid_undulation
255
+
256
+
257
+ def wgs84_to_msl(altitude_wgs84: float, latitude: float, longitude: float) -> float:
258
+ """Convert WGS84 ellipsoid height to Mean Sea Level (MSL) altitude.
259
+
260
+ This is the inverse of msl_to_wgs84. Useful when you have WGS84 heights
261
+ (e.g., from Cesium) and need to display MSL altitudes to users.
262
+
263
+ altitude_msl = WGS84_height - geoid_undulation
264
+
265
+ Parameters
266
+ ----------
267
+ altitude_wgs84 : float
268
+ Height above WGS84 ellipsoid in meters
269
+ latitude : float
270
+ Latitude in degrees (-90 to 90)
271
+ longitude : float
272
+ Longitude in degrees (-180 to 180 or 0 to 360)
273
+
274
+ Returns
275
+ -------
276
+ float
277
+ Altitude above Mean Sea Level in meters
278
+
279
+ Examples
280
+ --------
281
+ >>> # WGS84 height of ~47m in France is approximately sea level
282
+ >>> msl_alt = wgs84_to_msl(47, 46.0, 4.0)
283
+ >>> -3 < msl_alt < 3
284
+ True
285
+ """
286
+ geoid_undulation = get_geoid_undulation(latitude, longitude)
287
+ return altitude_wgs84 - geoid_undulation
288
+
289
+
290
+ def clear_geoid_cache() -> None:
291
+ """Clear the cached geoid model and undulation values.
292
+
293
+ This function clears both the geoid model cache and the LRU cache
294
+ used by get_geoid_undulation. Useful for testing or to force reloading
295
+ the geoid data.
296
+ """
297
+ global _geoid_model
298
+ _geoid_model = None
299
+ get_geoid_undulation.cache_clear()