cesiumjs-anywidget 0.6.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.
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/PKG-INFO +45 -6
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/README.md +30 -0
- cesiumjs_anywidget-0.7.0/jest.config.js +18 -0
- cesiumjs_anywidget-0.7.0/package.json +21 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/pyproject.toml +15 -6
- cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/__init__.py +27 -0
- cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/exif_utils.py +281 -0
- cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/geoid.py +299 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/src/cesiumjs_anywidget/index.js +1415 -490
- cesiumjs_anywidget-0.7.0/src/cesiumjs_anywidget/logger.py +73 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/src/cesiumjs_anywidget/styles.css +24 -1
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/src/cesiumjs_anywidget/widget.py +552 -42
- cesiumjs_anywidget-0.7.0/uv.lock +2386 -0
- cesiumjs_anywidget-0.6.0/DEVELOPMENT.md +0 -141
- cesiumjs_anywidget-0.6.0/package.json +0 -15
- cesiumjs_anywidget-0.6.0/src/cesiumjs_anywidget/__init__.py +0 -6
- cesiumjs_anywidget-0.6.0/uv.lock +0 -3984
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/.gitignore +0 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/LICENSE +0 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/Makefile +0 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/TROUBLESHOOTING.md +0 -0
- {cesiumjs_anywidget-0.6.0 → cesiumjs_anywidget-0.7.0}/build.js +0 -0
- {cesiumjs_anywidget-0.6.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|