cesiumjs-anywidget 0.5.0__tar.gz → 0.7.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.
Files changed (24) hide show
  1. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/PKG-INFO +45 -6
  2. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/README.md +30 -0
  3. cesiumjs_anywidget-0.7.0/jest.config.js +18 -0
  4. cesiumjs_anywidget-0.7.0/package.json +21 -0
  5. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/pyproject.toml +15 -6
  6. cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/__init__.py +27 -0
  7. cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/exif_utils.py +281 -0
  8. cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/geoid.py +299 -0
  9. cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/index.js +2987 -0
  10. cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/logger.py +73 -0
  11. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/src/cesiumjs_anywidget/styles.css +24 -1
  12. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/src/cesiumjs_anywidget/widget.py +616 -42
  13. cesiumjs_anywidget-0.7.0/uv.lock +2386 -0
  14. cesiumjs_anywidget-0.5.0/DEVELOPMENT.md +0 -141
  15. cesiumjs_anywidget-0.5.0/package.json +0 -15
  16. cesiumjs_anywidget-0.5.0/src/cesiumjs_anywidget/__init__.py +0 -6
  17. cesiumjs_anywidget-0.5.0/src/cesiumjs_anywidget/index.js +0 -2023
  18. cesiumjs_anywidget-0.5.0/uv.lock +0 -3984
  19. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/.gitignore +0 -0
  20. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/LICENSE +0 -0
  21. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/Makefile +0 -0
  22. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/TROUBLESHOOTING.md +0 -0
  23. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/build.js +0 -0
  24. {cesiumjs_anywidget-0.5.0 → cesiumjs_anywidget-0.7.0}/fix_tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cesiumjs-anywidget
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: A Jupyter widget for CesiumJS 3D globe visualization using anywidget
5
5
  Project-URL: Homepage, https://github.com/Alex-PLACET/cesiumjs_anywidget
6
6
  Project-URL: Repository, https://github.com/Alex-PLACET/cesiumjs_anywidget
@@ -213,19 +213,28 @@ Classifier: Intended Audience :: Developers
213
213
  Classifier: Intended Audience :: Science/Research
214
214
  Classifier: License :: OSI Approved :: Apache Software License
215
215
  Classifier: Programming Language :: Python :: 3
216
- Classifier: Programming Language :: Python :: 3.8
217
- Classifier: Programming Language :: Python :: 3.9
218
- Classifier: Programming Language :: Python :: 3.10
219
- Classifier: Programming Language :: Python :: 3.11
220
216
  Classifier: Programming Language :: Python :: 3.12
221
- Requires-Python: >=3.8
217
+ Requires-Python: >=3.12
222
218
  Requires-Dist: anywidget>=0.9.0
219
+ Requires-Dist: exifread>=3.5.1
220
+ Requires-Dist: ipycanvas>=0.14.3
221
+ Requires-Dist: jupyterlab>=4.3.8
222
+ Requires-Dist: matplotlib>=3.7.5
223
+ Requires-Dist: numpy>=1.24.4
224
+ Requires-Dist: opencv-python>=4.11.0.86
225
+ Requires-Dist: pillow>=10.4.0
226
+ Requires-Dist: pygeodesy>=25.11.5
227
+ Requires-Dist: scipy>=1.10.1
223
228
  Requires-Dist: traitlets>=5.0.0
224
229
  Provides-Extra: dev
230
+ Requires-Dist: huggingface-hub>=1.3.4; extra == 'dev'
225
231
  Requires-Dist: jupyterlab>=4.0.0; extra == 'dev'
226
232
  Requires-Dist: notebook>=7.0.0; extra == 'dev'
233
+ Requires-Dist: piexif>=1.1.3; extra == 'dev'
234
+ Requires-Dist: pyarrow>=23.0.0; extra == 'dev'
227
235
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
228
236
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
237
+ Requires-Dist: sidecar>=0.8.0; extra == 'dev'
229
238
  Description-Content-Type: text/markdown
230
239
 
231
240
  # CesiumJS Anywidget
@@ -239,6 +248,7 @@ A Jupyter widget for interactive 3D globe visualization using [CesiumJS](https:/
239
248
  - 🔄 **Bidirectional Sync**: Camera state syncs between Python and JavaScript
240
249
  - 🗺️ **GeoJSON Support**: Load and visualize GeoJSON data
241
250
  - 🏔️ **Terrain & Imagery**: World terrain and satellite imagery
251
+ - 🏙️ **Photorealistic 3D Tiles**: Google's photorealistic global 3D cities and landscapes
242
252
  - 📏 **Measurement Tools**: Built-in distance, multi-point, and height measurement tools
243
253
  - ⚙️ **Highly Configurable**: Customize viewer options and UI elements
244
254
 
@@ -393,6 +403,35 @@ widget = CesiumWidget(
393
403
  )
394
404
  ```
395
405
 
406
+ ### Google Photorealistic 3D Tiles
407
+
408
+ Visualize cities and landscapes in stunning photorealistic detail using Google's Photorealistic 3D Tiles:
409
+
410
+ ```python
411
+ # Enable photorealistic tiles (recommended method)
412
+ widget = CesiumWidget()
413
+ widget.enable_photorealistic_3d_tiles(True)
414
+
415
+ # Fly to a city to see the tiles
416
+ widget.fly_to(latitude=40.7128, longitude=-74.0060, altitude=2000, pitch=-45)
417
+
418
+ # Or configure manually during creation
419
+ widget = CesiumWidget(
420
+ enable_photorealistic_tiles=True,
421
+ show_globe=False, # Disable base globe (recommended)
422
+ enable_terrain=False, # Disable terrain (tiles include terrain)
423
+ height="700px"
424
+ )
425
+ ```
426
+
427
+ **Features:**
428
+ - Global coverage of major cities and populated areas
429
+ - Photorealistic imagery with 3D buildings and structures
430
+ - Automatic terrain integration
431
+ - Works with all camera controls and measurement tools
432
+
433
+ See [PHOTOREALISTIC_TILES.md](PHOTOREALISTIC_TILES.md) for detailed documentation and [examples/photorealistic_tiles_demo.ipynb](examples/photorealistic_tiles_demo.ipynb) for interactive examples.
434
+
396
435
  ## Development
397
436
 
398
437
  Enable hot module replacement for live updates during development:
@@ -9,6 +9,7 @@ A Jupyter widget for interactive 3D globe visualization using [CesiumJS](https:/
9
9
  - 🔄 **Bidirectional Sync**: Camera state syncs between Python and JavaScript
10
10
  - 🗺️ **GeoJSON Support**: Load and visualize GeoJSON data
11
11
  - 🏔️ **Terrain & Imagery**: World terrain and satellite imagery
12
+ - 🏙️ **Photorealistic 3D Tiles**: Google's photorealistic global 3D cities and landscapes
12
13
  - 📏 **Measurement Tools**: Built-in distance, multi-point, and height measurement tools
13
14
  - ⚙️ **Highly Configurable**: Customize viewer options and UI elements
14
15
 
@@ -163,6 +164,35 @@ widget = CesiumWidget(
163
164
  )
164
165
  ```
165
166
 
167
+ ### Google Photorealistic 3D Tiles
168
+
169
+ Visualize cities and landscapes in stunning photorealistic detail using Google's Photorealistic 3D Tiles:
170
+
171
+ ```python
172
+ # Enable photorealistic tiles (recommended method)
173
+ widget = CesiumWidget()
174
+ widget.enable_photorealistic_3d_tiles(True)
175
+
176
+ # Fly to a city to see the tiles
177
+ widget.fly_to(latitude=40.7128, longitude=-74.0060, altitude=2000, pitch=-45)
178
+
179
+ # Or configure manually during creation
180
+ widget = CesiumWidget(
181
+ enable_photorealistic_tiles=True,
182
+ show_globe=False, # Disable base globe (recommended)
183
+ enable_terrain=False, # Disable terrain (tiles include terrain)
184
+ height="700px"
185
+ )
186
+ ```
187
+
188
+ **Features:**
189
+ - Global coverage of major cities and populated areas
190
+ - Photorealistic imagery with 3D buildings and structures
191
+ - Automatic terrain integration
192
+ - Works with all camera controls and measurement tools
193
+
194
+ See [PHOTOREALISTIC_TILES.md](PHOTOREALISTIC_TILES.md) for detailed documentation and [examples/photorealistic_tiles_demo.ipynb](examples/photorealistic_tiles_demo.ipynb) for interactive examples.
195
+
166
196
  ## Development
167
197
 
168
198
  Enable hot module replacement for live updates during development:
@@ -0,0 +1,18 @@
1
+ export default {
2
+ testEnvironment: 'jsdom',
3
+ testMatch: ['**/tests/js/**/*.test.js'],
4
+ transform: {},
5
+ moduleNameMapper: {
6
+ '\\.(css|less|scss|sass)$': '<rootDir>/tests/js/__mocks__/styleMock.js',
7
+ },
8
+ setupFilesAfterEnv: ['<rootDir>/tests/js/setup.js'],
9
+ collectCoverageFrom: [
10
+ 'src/cesiumjs_anywidget/js/**/*.js',
11
+ '!src/cesiumjs_anywidget/js/index.js', // Skip main entry point for now
12
+ ],
13
+ coverageDirectory: 'coverage',
14
+ coverageReporters: ['text', 'lcov', 'html'],
15
+ verbose: true,
16
+ testPathIgnorePatterns: ['/node_modules/', '/.venv/', '/htmlcov/'],
17
+ modulePathIgnorePatterns: ['<rootDir>/.venv/', '<rootDir>/htmlcov/'],
18
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "cesiumjs-anywidget",
3
+ "version": "0.1.0",
4
+ "description": "A Jupyter widget for CesiumJS 3D globe visualization",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "node build.js",
9
+ "watch": "node build.js --watch",
10
+ "dev": "node build.js --watch",
11
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
12
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
13
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
14
+ },
15
+ "devDependencies": {
16
+ "@jest/globals": "^29.7.0",
17
+ "esbuild": "^0.27.1",
18
+ "jest": "^29.7.0",
19
+ "jest-environment-jsdom": "^29.7.0"
20
+ }
21
+ }
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cesiumjs-anywidget"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "A Jupyter widget for CesiumJS 3D globe visualization using anywidget"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
11
  authors = [
12
12
  { name = "Alexis Placet" }
13
13
  ]
14
- requires-python = ">=3.8"
14
+ requires-python = ">=3.12"
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
17
17
  "Framework :: Jupyter",
@@ -19,14 +19,19 @@ classifiers = [
19
19
  "Intended Audience :: Science/Research",
20
20
  "License :: OSI Approved :: Apache Software License",
21
21
  "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3.8",
23
- "Programming Language :: Python :: 3.9",
24
- "Programming Language :: Python :: 3.10",
25
- "Programming Language :: Python :: 3.11",
26
22
  "Programming Language :: Python :: 3.12",
27
23
  ]
28
24
  dependencies = [
29
25
  "anywidget>=0.9.0",
26
+ "exifread>=3.5.1",
27
+ "ipycanvas>=0.14.3",
28
+ "jupyterlab>=4.3.8",
29
+ "matplotlib>=3.7.5",
30
+ "numpy>=1.24.4",
31
+ "opencv-python>=4.11.0.86",
32
+ "pillow>=10.4.0",
33
+ "pygeodesy>=25.11.5",
34
+ "scipy>=1.10.1",
30
35
  "traitlets>=5.0.0",
31
36
  ]
32
37
 
@@ -34,8 +39,12 @@ dependencies = [
34
39
  dev = [
35
40
  "jupyterlab>=4.0.0",
36
41
  "notebook>=7.0.0",
42
+ "piexif>=1.1.3",
37
43
  "pytest>=7.0.0",
38
44
  "pytest-cov>=4.0.0",
45
+ "pyarrow>=23.0.0",
46
+ "huggingface_hub>=1.3.4",
47
+ "sidecar>=0.8.0",
39
48
  ]
40
49
 
41
50
  [project.urls]
@@ -0,0 +1,27 @@
1
+ """CesiumJS Anywidget - A Jupyter widget for CesiumJS 3D globe visualization."""
2
+
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
13
+
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