geopic-tag-reader 1.0.6__tar.gz → 1.1.1__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 (66) hide show
  1. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/CHANGELOG.md +17 -1
  2. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/PKG-INFO +4 -4
  3. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/API_USAGE.md +1 -1
  4. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/model.md +3 -1
  5. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/reader.md +38 -25
  6. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/__init__.py +1 -1
  7. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/main.py +3 -1
  8. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/reader.py +92 -14
  9. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/writer.py +2 -2
  10. geopic_tag_reader-1.1.1/tests/fixtures/charset.jpg +0 -0
  11. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/test_reader.py +47 -26
  12. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/test_writer.py +3 -3
  13. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/.gitignore +0 -0
  14. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/.gitlab-ci.yml +0 -0
  15. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/.pre-commit-config.yaml +0 -0
  16. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/CODE_OF_CONDUCT.md +0 -0
  17. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/LICENSE +0 -0
  18. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/Makefile +0 -0
  19. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/README.md +0 -0
  20. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/.pages +0 -0
  21. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/CLI_USAGE.md +0 -0
  22. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/Develop.md +0 -0
  23. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/Install.md +0 -0
  24. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/camera.md +0 -0
  25. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/docs/writer.md +0 -0
  26. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/camera.py +0 -0
  27. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/model.py +0 -0
  28. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/geopic_tag_reader/py.typed +0 -0
  29. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/pyproject.toml +3 -3
  30. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/pytest.ini +0 -0
  31. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/__init__.py +0 -0
  32. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/conftest.py +0 -0
  33. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/1.jpg +0 -0
  34. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/IMG_20210720_144918.jpg +0 -0
  35. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/IMG_20210720_161352.jpg +0 -0
  36. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/a1.jpg +0 -0
  37. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/b1.jpg +0 -0
  38. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/broken_makernotes.jpg +0 -0
  39. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/c1.jpg +0 -0
  40. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/cropped.jpg +0 -0
  41. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/d1.jpg +0 -0
  42. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/datetime_ms_float.jpg +0 -0
  43. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/datetime_offset.jpg +0 -0
  44. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/e1.jpg +0 -0
  45. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/flat.jpg +0 -0
  46. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/gopromax_flat.jpg +0 -0
  47. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/gps_date_slash.jpg +0 -0
  48. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/gps_date_time_stamp.jpg +0 -0
  49. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_Ricoh_Theta.jpg +0 -0
  50. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_V4MPack.jpg +0 -0
  51. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_categorisee.jpg +0 -0
  52. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_datetimeoriginal.jpg +0 -0
  53. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_gps_date_string.jpg +0 -0
  54. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_gps_datestamp.jpg +0 -0
  55. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_gps_sotm.jpg +0 -0
  56. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_invalid_gps_date.jpg +0 -0
  57. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_without_coord.jpg +0 -0
  58. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_without_dt.jpg +0 -0
  59. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/img_without_exif_tags.jpg +0 -0
  60. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/insta360_date.jpg +0 -0
  61. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/int_long_tag.jpg +0 -0
  62. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/out_of_bounds_lat.jpg +0 -0
  63. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/out_of_bounds_lon.jpg +0 -0
  64. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/pic_with_float_lat.jpg +0 -0
  65. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/fixtures/ricoh_theta_no_projection.jpg +0 -0
  66. {geopic_tag_reader-1.0.6 → geopic_tag_reader-1.1.1}/tests/test_main.py +0 -0
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.1] - 2024-04-26
11
+
12
+ ### Added
13
+
14
+ - Reader now handles pitch & roll values from various EXIF/XMP tags.
15
+
16
+ ## [1.1.0] - 2024-04-17
17
+
18
+ ### Changed
19
+
20
+ - Encoding information (`charset=...`) is now stripped out of text EXIF tags.
21
+ - `GeoPicTags` objects returns `ts` as a Python `datetime` object (instead of decimal epoch).
22
+ - Improved timezone handling in reader, GPS coordinates are used to find appropriate timezone when no timezone is defined in EXIF tags. If a UTC fallback is done, a warning is thrown.
23
+
10
24
  ## [1.0.6] - 2024-04-02
11
25
 
12
26
  ### Changed
@@ -152,7 +166,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
152
166
 
153
167
  - EXIF tag reading methods extracted from [GeoVisio API](https://gitlab.com/geovisio/api)
154
168
 
155
- [Unreleased]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.0.6...main
169
+ [Unreleased]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.1.1...main
170
+ [1.1.1]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.1.0...1.1.1
171
+ [1.1.0]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.0.6...1.1.0
156
172
  [1.0.6]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.0.5...1.0.6
157
173
  [1.0.5]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.0.4...1.0.5
158
174
  [1.0.4]: https://gitlab.com/geovisio/geo-picture-tag-reader/-/compare/1.0.3...1.0.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geopic-tag-reader
3
- Version: 1.0.6
3
+ Version: 1.1.1
4
4
  Summary: GeoPicTagReader
5
5
  Author-email: Adrien PAVIE <panieravide@riseup.net>
6
6
  Requires-Python: >=3.8
@@ -9,6 +9,9 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Requires-Dist: typer ~= 0.12
10
10
  Requires-Dist: xmltodict ~= 0.13
11
11
  Requires-Dist: pyexiv2 == 2.8.3
12
+ Requires-Dist: timezonefinder == 6.2.0
13
+ Requires-Dist: pytz ~= 2023.3
14
+ Requires-Dist: types-pytz ~= 2023.3.0.1
12
15
  Requires-Dist: flit ~= 3.8.0 ; extra == "build"
13
16
  Requires-Dist: black ~= 24.3 ; extra == "dev"
14
17
  Requires-Dist: mypy ~= 1.9 ; extra == "dev"
@@ -17,9 +20,6 @@ Requires-Dist: pytest-datafiles ~= 3.0 ; extra == "dev"
17
20
  Requires-Dist: lazydocs ~= 0.4.8 ; extra == "dev"
18
21
  Requires-Dist: types-xmltodict ~= 0.13 ; extra == "dev"
19
22
  Requires-Dist: pre-commit ~= 3.3.3 ; extra == "dev"
20
- Requires-Dist: timezonefinder == 6.2.0 ; extra == "write-exif"
21
- Requires-Dist: pytz ~= 2023.3 ; extra == "write-exif"
22
- Requires-Dist: types-pytz ~= 2023.3.0.1 ; extra == "write-exif"
23
23
  Requires-Dist: python-dateutil ~= 2.8.2 ; extra == "write-exif"
24
24
  Project-URL: Home, https://gitlab.com/geovisio/geo-picture-tag-reader
25
25
  Provides-Extra: build
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## Classes
13
13
 
14
- - [`model.PictureType`](./model.md#class-picturetype): An enumeration.
14
+ - [`model.PictureType`](./model.md#class-picturetype)
15
15
  - [`reader.CropValues`](./reader.md#class-cropvalues): Cropped equirectangular pictures metadata
16
16
  - [`reader.GeoPicTags`](./reader.md#class-geopictags): Tags associated to a geolocated picture
17
17
  - [`reader.InvalidExifException`](./reader.md#class-invalidexifexception): Exception for invalid EXIF information from image
@@ -14,7 +14,9 @@
14
14
  <a href="../geopic_tag_reader/model.py#L4"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
15
15
 
16
16
  ## <kbd>class</kbd> `PictureType`
17
- An enumeration.
17
+
18
+
19
+
18
20
 
19
21
 
20
22
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  ---
12
12
 
13
- <a href="../geopic_tag_reader/reader.py#L114"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
13
+ <a href="../geopic_tag_reader/reader.py#L124"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
14
14
 
15
15
  ## <kbd>function</kbd> `readPictureMetadata`
16
16
 
@@ -35,7 +35,7 @@ Extracts metadata from picture file
35
35
 
36
36
  ---
37
37
 
38
- <a href="../geopic_tag_reader/reader.py#L346"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
38
+ <a href="../geopic_tag_reader/reader.py#L394"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
39
39
 
40
40
  ## <kbd>function</kbd> `decodeMakeModel`
41
41
 
@@ -48,7 +48,7 @@ Python 2/3 compatible decoding of make/model field.
48
48
 
49
49
  ---
50
50
 
51
- <a href="../geopic_tag_reader/reader.py#L357"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
51
+ <a href="../geopic_tag_reader/reader.py#L405"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
52
52
 
53
53
  ## <kbd>function</kbd> `isValidManyFractions`
54
54
 
@@ -63,7 +63,7 @@ isValidManyFractions(value: str) → bool
63
63
 
64
64
  ---
65
65
 
66
- <a href="../geopic_tag_reader/reader.py#L364"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
66
+ <a href="../geopic_tag_reader/reader.py#L412"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
67
67
 
68
68
  ## <kbd>function</kbd> `decodeManyFractions`
69
69
 
@@ -76,7 +76,7 @@ Try to decode a list of fractions, separated by spaces
76
76
 
77
77
  ---
78
78
 
79
- <a href="../geopic_tag_reader/reader.py#L377"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
79
+ <a href="../geopic_tag_reader/reader.py#L425"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
80
80
 
81
81
  ## <kbd>function</kbd> `decodeLatLon`
82
82
 
@@ -92,14 +92,16 @@ Reads GPS info from given group to get latitude/longitude as float coordinates
92
92
 
93
93
  ---
94
94
 
95
- <a href="../geopic_tag_reader/reader.py#L432"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
95
+ <a href="../geopic_tag_reader/reader.py#L480"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
96
96
 
97
97
  ## <kbd>function</kbd> `decodeDateTimeOriginal`
98
98
 
99
99
  ```python
100
100
  decodeDateTimeOriginal(
101
101
  data: dict,
102
- datetimeField: str
102
+ datetimeField: str,
103
+ lat: Optional[float] = None,
104
+ lon: Optional[float] = None
103
105
  ) → Tuple[Optional[datetime], List[str]]
104
106
  ```
105
107
 
@@ -110,7 +112,7 @@ decodeDateTimeOriginal(
110
112
 
111
113
  ---
112
114
 
113
- <a href="../geopic_tag_reader/reader.py#L464"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
115
+ <a href="../geopic_tag_reader/reader.py#L534"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
114
116
 
115
117
  ## <kbd>function</kbd> `decodeTimeOffset`
116
118
 
@@ -125,12 +127,17 @@ decodeTimeOffset(data: dict, offsetTimeField: str) → Optional[tzinfo]
125
127
 
126
128
  ---
127
129
 
128
- <a href="../geopic_tag_reader/reader.py#L470"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
130
+ <a href="../geopic_tag_reader/reader.py#L540"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
129
131
 
130
132
  ## <kbd>function</kbd> `decodeGPSDateTime`
131
133
 
132
134
  ```python
133
- decodeGPSDateTime(data: dict, group: str) → Tuple[Optional[datetime], List[str]]
135
+ decodeGPSDateTime(
136
+ data: dict,
137
+ group: str,
138
+ lat: Optional[float] = None,
139
+ lon: Optional[float] = None
140
+ ) → Tuple[Optional[datetime], List[str]]
134
141
  ```
135
142
 
136
143
 
@@ -140,7 +147,7 @@ decodeGPSDateTime(data: dict, group: str) → Tuple[Optional[datetime], List[str
140
147
 
141
148
  ---
142
149
 
143
- <a href="../geopic_tag_reader/reader.py#L513"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
150
+ <a href="../geopic_tag_reader/reader.py#L591"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
144
151
 
145
152
  ## <kbd>function</kbd> `decodeSecondsAndMicroSeconds`
146
153
 
@@ -158,7 +165,7 @@ decodeSecondsAndMicroSeconds(
158
165
 
159
166
  ---
160
167
 
161
- <a href="../geopic_tag_reader/reader.py#L539"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
168
+ <a href="../geopic_tag_reader/reader.py#L617"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
162
169
 
163
170
  ## <kbd>function</kbd> `isExifTagUsable`
164
171
 
@@ -185,7 +192,7 @@ Is a given EXIF tag usable (not null and not an empty string)
185
192
 
186
193
  ---
187
194
 
188
- <a href="../geopic_tag_reader/reader.py#L16"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
195
+ <a href="../geopic_tag_reader/reader.py#L20"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
189
196
 
190
197
  ## <kbd>class</kbd> `CropValues`
191
198
  Cropped equirectangular pictures metadata
@@ -226,7 +233,7 @@ __init__(
226
233
 
227
234
  ---
228
235
 
229
- <a href="../geopic_tag_reader/reader.py#L37"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
236
+ <a href="../geopic_tag_reader/reader.py#L41"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
230
237
 
231
238
  ## <kbd>class</kbd> `GeoPicTags`
232
239
  Tags associated to a geolocated picture
@@ -237,8 +244,8 @@ Tags associated to a geolocated picture
237
244
 
238
245
  - <b>`lat`</b> (float): GPS Latitude (in WGS84)
239
246
  - <b>`lon`</b> (float): GPS Longitude (in WGS84)
240
- - <b>`ts`</b> (float): The capture date (as POSIX timestamp)
241
- - <b>`heading`</b> (int): Picture heading (in degrees, North = 0°, East = 90°, South = 180°, West = 270°)
247
+ - <b>`ts`</b> (datetime): The capture date (date & time with timezone)
248
+ - <b>`heading`</b> (int): Picture heading/yaw (in degrees, North = 0°, East = 90°, South = 180°, West = 270°)
242
249
  - <b>`type`</b> (str): The kind of picture (flat, equirectangular)
243
250
  - <b>`make`</b> (str): The camera manufacturer name
244
251
  - <b>`model`</b> (str): The camera model name
@@ -247,6 +254,8 @@ Tags associated to a geolocated picture
247
254
  - <b>`exif`</b> (dict[str, str]): Raw EXIF tags from picture (following Exiv2 naming scheme, see https://exiv2.org/metadata.html)
248
255
  - <b>`tagreader_warnings`</b> (list[str]): List of thrown warnings during metadata reading
249
256
  - <b>`altitude`</b> (float): altitude (in m) (optional)
257
+ - <b>`pitch`</b> (float): Picture pitch angle, compared to horizon (in degrees, bottom = -90°, horizon = 0°, top = 90°)
258
+ - <b>`roll`</b> (float): Picture roll angle, on a right/left axis (in degrees, left-arm down = -90°, flat = 0°, right-arm down = 90°)
250
259
 
251
260
 
252
261
 
@@ -260,7 +269,7 @@ Implementation note: this needs to be sync with the PartialGeoPicTags structure
260
269
  __init__(
261
270
  lat: float,
262
271
  lon: float,
263
- ts: float,
272
+ ts: datetime,
264
273
  heading: Optional[int],
265
274
  type: str,
266
275
  make: Optional[str],
@@ -269,7 +278,9 @@ __init__(
269
278
  crop: Optional[CropValues],
270
279
  exif: Dict[str, str] = <factory>,
271
280
  tagreader_warnings: List[str] = <factory>,
272
- altitude: Optional[float] = None
281
+ altitude: Optional[float] = None,
282
+ pitch: Optional[float] = None,
283
+ roll: Optional[float] = None
273
284
  ) → None
274
285
  ```
275
286
 
@@ -283,12 +294,12 @@ __init__(
283
294
 
284
295
  ---
285
296
 
286
- <a href="../geopic_tag_reader/reader.py#L73"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
297
+ <a href="../geopic_tag_reader/reader.py#L81"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
287
298
 
288
299
  ## <kbd>class</kbd> `InvalidExifException`
289
300
  Exception for invalid EXIF information from image
290
301
 
291
- <a href="../geopic_tag_reader/reader.py#L76"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
302
+ <a href="../geopic_tag_reader/reader.py#L84"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
292
303
 
293
304
  ### <kbd>method</kbd> `__init__`
294
305
 
@@ -306,7 +317,7 @@ __init__(msg)
306
317
 
307
318
  ---
308
319
 
309
- <a href="../geopic_tag_reader/reader.py#L80"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
320
+ <a href="../geopic_tag_reader/reader.py#L88"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
310
321
 
311
322
  ## <kbd>class</kbd> `PartialGeoPicTags`
312
323
  Tags associated to a geolocated picture when not all tags have been found
@@ -321,7 +332,7 @@ Implementation note: this needs to be sync with the GeoPicTags structure
321
332
  __init__(
322
333
  lat: Optional[float] = None,
323
334
  lon: Optional[float] = None,
324
- ts: Optional[float] = None,
335
+ ts: Optional[datetime] = None,
325
336
  heading: Optional[int] = None,
326
337
  type: Optional[str] = None,
327
338
  make: Optional[str] = None,
@@ -330,7 +341,9 @@ __init__(
330
341
  crop: Optional[CropValues] = None,
331
342
  exif: Dict[str, str] = <factory>,
332
343
  tagreader_warnings: List[str] = <factory>,
333
- altitude: Optional[float] = None
344
+ altitude: Optional[float] = None,
345
+ pitch: Optional[float] = None,
346
+ roll: Optional[float] = None
334
347
  ) → None
335
348
  ```
336
349
 
@@ -344,14 +357,14 @@ __init__(
344
357
 
345
358
  ---
346
359
 
347
- <a href="../geopic_tag_reader/reader.py#L101"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
360
+ <a href="../geopic_tag_reader/reader.py#L111"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
348
361
 
349
362
  ## <kbd>class</kbd> `PartialExifException`
350
363
  Exception for partial / missing EXIF information from image
351
364
 
352
365
  Contains a PartialGeoPicTags with all tags that have been read and the list of missing tags
353
366
 
354
- <a href="../geopic_tag_reader/reader.py#L108"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
367
+ <a href="../geopic_tag_reader/reader.py#L118"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
355
368
 
356
369
  ### <kbd>method</kbd> `__init__`
357
370
 
@@ -2,4 +2,4 @@
2
2
  GeoPicTagReader
3
3
  """
4
4
 
5
- __version__ = "1.0.6"
5
+ __version__ = "1.1.1"
@@ -22,13 +22,15 @@ def read(
22
22
 
23
23
  print("Latitude:", metadata.lat)
24
24
  print("Longitude:", metadata.lon)
25
- print("Timestamp:", metadata.ts)
25
+ print("Timestamp:", metadata.ts.isoformat())
26
26
  print("Heading:", metadata.heading)
27
27
  print("Type:", metadata.type)
28
28
  print("Make:", metadata.make)
29
29
  print("Model:", metadata.model)
30
30
  print("Focal length:", metadata.focal_length)
31
31
  print("Crop parameters:", metadata.crop)
32
+ print("Pitch:", metadata.pitch)
33
+ print("Roll:", metadata.roll)
32
34
 
33
35
  if len(metadata.tagreader_warnings) > 0:
34
36
  print("Warnings raised by reader:")
@@ -7,11 +7,15 @@ import re
7
7
  import json
8
8
  from fractions import Fraction
9
9
  from geopic_tag_reader import camera
10
+ import timezonefinder # type: ignore
11
+ import pytz
10
12
 
11
13
  # This is a fix for invalid MakerNotes leading to picture not read at all
12
14
  # https://github.com/LeoHsiao1/pyexiv2/issues/58
13
15
  pyexiv2.set_log_level(4)
14
16
 
17
+ tz_finder = timezonefinder.TimezoneFinder()
18
+
15
19
 
16
20
  @dataclass
17
21
  class CropValues:
@@ -41,8 +45,8 @@ class GeoPicTags:
41
45
  Attributes:
42
46
  lat (float): GPS Latitude (in WGS84)
43
47
  lon (float): GPS Longitude (in WGS84)
44
- ts (float): The capture date (as POSIX timestamp)
45
- heading (int): Picture heading (in degrees, North = 0°, East = 90°, South = 180°, West = 270°)
48
+ ts (datetime): The capture date (date & time with timezone)
49
+ heading (int): Picture heading/yaw (in degrees, North = 0°, East = 90°, South = 180°, West = 270°)
46
50
  type (str): The kind of picture (flat, equirectangular)
47
51
  make (str): The camera manufacturer name
48
52
  model (str): The camera model name
@@ -51,6 +55,8 @@ class GeoPicTags:
51
55
  exif (dict[str, str]): Raw EXIF tags from picture (following Exiv2 naming scheme, see https://exiv2.org/metadata.html)
52
56
  tagreader_warnings (list[str]): List of thrown warnings during metadata reading
53
57
  altitude (float): altitude (in m) (optional)
58
+ pitch (float): Picture pitch angle, compared to horizon (in degrees, bottom = -90°, horizon = 0°, top = 90°)
59
+ roll (float): Picture roll angle, on a right/left axis (in degrees, left-arm down = -90°, flat = 0°, right-arm down = 90°)
54
60
 
55
61
 
56
62
  Implementation note: this needs to be sync with the PartialGeoPicTags structure
@@ -58,7 +64,7 @@ class GeoPicTags:
58
64
 
59
65
  lat: float
60
66
  lon: float
61
- ts: float
67
+ ts: datetime.datetime
62
68
  heading: Optional[int]
63
69
  type: str
64
70
  make: Optional[str]
@@ -68,6 +74,8 @@ class GeoPicTags:
68
74
  exif: Dict[str, str] = field(default_factory=lambda: {})
69
75
  tagreader_warnings: List[str] = field(default_factory=lambda: [])
70
76
  altitude: Optional[float] = None
77
+ pitch: Optional[float] = None
78
+ roll: Optional[float] = None
71
79
 
72
80
 
73
81
  class InvalidExifException(Exception):
@@ -86,7 +94,7 @@ class PartialGeoPicTags:
86
94
 
87
95
  lat: Optional[float] = None
88
96
  lon: Optional[float] = None
89
- ts: Optional[float] = None
97
+ ts: Optional[datetime.datetime] = None
90
98
  heading: Optional[int] = None
91
99
  type: Optional[str] = None
92
100
  make: Optional[str] = None
@@ -96,6 +104,8 @@ class PartialGeoPicTags:
96
104
  exif: Dict[str, str] = field(default_factory=lambda: {})
97
105
  tagreader_warnings: List[str] = field(default_factory=lambda: [])
98
106
  altitude: Optional[float] = None
107
+ pitch: Optional[float] = None
108
+ roll: Optional[float] = None
99
109
 
100
110
 
101
111
  class PartialExifException(Exception):
@@ -142,6 +152,11 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
142
152
  except:
143
153
  pass
144
154
 
155
+ # Sanitize charset information
156
+ for k, v in data.items():
157
+ if isinstance(v, str):
158
+ data[k] = re.sub(r"charset=[^\s]+", "", v).strip()
159
+
145
160
  # Parse latitude/longitude
146
161
  lat, lon, llw = decodeLatLon(data, "Exif.GPSInfo")
147
162
  if len(llw) > 0:
@@ -163,13 +178,13 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
163
178
  raise InvalidExifException("Read longitude is out of WGS84 bounds (should be in [-180, 180])")
164
179
 
165
180
  # Parse date/time
166
- d, llw = decodeGPSDateTime(data, "Exif.GPSInfo")
181
+ d, llw = decodeGPSDateTime(data, "Exif.GPSInfo", lat, lon)
167
182
 
168
183
  if len(llw) > 0:
169
184
  warnings.extend(llw)
170
185
 
171
186
  if d is None:
172
- d, llw = decodeGPSDateTime(data, "Xmp.exif")
187
+ d, llw = decodeGPSDateTime(data, "Xmp.exif", lat, lon)
173
188
  if len(llw) > 0:
174
189
  warnings.extend(llw)
175
190
 
@@ -180,7 +195,7 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
180
195
  "Xmp.GPano.SourceImageCreateTime",
181
196
  ]:
182
197
  if d is None:
183
- d, llw = decodeDateTimeOriginal(data, exifField)
198
+ d, llw = decodeDateTimeOriginal(data, exifField, lat, lon)
184
199
  if len(llw) > 0:
185
200
  warnings.extend(llw)
186
201
 
@@ -204,7 +219,7 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
204
219
  except Exception as e:
205
220
  warnings.append("Skipping Mapillary date/time as it was not recognized:\n\t" + str(e))
206
221
 
207
- # Heading
222
+ # Heading/Yaw
208
223
  heading = None
209
224
  if isExifTagUsable(data, "Xmp.GPano.PoseHeadingDegrees", float) and isExifTagUsable(data, "Exif.GPSInfo.GPSImgDirection", Fraction):
210
225
  gpsDir = int(round(float(Fraction(data["Exif.GPSInfo.GPSImgDirection"]))))
@@ -227,6 +242,35 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
227
242
  elif "MAPCompassHeading" in data and isExifTagUsable(data["MAPCompassHeading"], "TrueHeading", float):
228
243
  heading = int(round(float(data["MAPCompassHeading"]["TrueHeading"])))
229
244
 
245
+ # Pitch & roll
246
+ pitch = None
247
+ roll = None
248
+ exifPRFields = ["Xmp.Camera.$$", "Exif.GPSInfo.GPS$$", "Xmp.GPano.Pose$$Degrees", "Xmp.GPano.InitialView$$Degrees"]
249
+ # For each potential EXIF field
250
+ for exifField in exifPRFields:
251
+ # Try out both Pitch & Roll variants
252
+ for checkField in ["Pitch", "Roll"]:
253
+ exifCheckField = exifField.replace("$$", checkField)
254
+ foundValue = None
255
+ # Look for float or fraction
256
+ if isExifTagUsable(data, exifCheckField, float):
257
+ foundValue = float(data[exifCheckField])
258
+ elif isExifTagUsable(data, exifCheckField, Fraction):
259
+ foundValue = float(Fraction(data[exifCheckField]))
260
+
261
+ # Save to correct variable (if not already set)
262
+ if foundValue is not None:
263
+ if checkField == "Pitch":
264
+ if pitch is None:
265
+ pitch = foundValue
266
+ else:
267
+ continue
268
+ elif checkField == "Roll":
269
+ if roll is None:
270
+ roll = foundValue
271
+ else:
272
+ continue
273
+
230
274
  # Make and model
231
275
  make = data.get("Exif.Image.Make") or data.get("MAPDeviceMake")
232
276
  model = data.get("Exif.Image.Model") or data.get("MAPDeviceModel")
@@ -313,7 +357,7 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
313
357
  PartialGeoPicTags(
314
358
  lat,
315
359
  lon,
316
- d.timestamp() if d else None,
360
+ d,
317
361
  heading,
318
362
  pic_type,
319
363
  make,
@@ -323,6 +367,8 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
323
367
  exif=data,
324
368
  tagreader_warnings=warnings,
325
369
  altitude=altitude,
370
+ pitch=pitch,
371
+ roll=roll,
326
372
  ),
327
373
  )
328
374
 
@@ -330,7 +376,7 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
330
376
  return GeoPicTags(
331
377
  lat,
332
378
  lon,
333
- d.timestamp(),
379
+ d,
334
380
  heading,
335
381
  pic_type,
336
382
  make,
@@ -340,6 +386,8 @@ def readPictureMetadata(picture: bytes) -> GeoPicTags:
340
386
  exif=data,
341
387
  tagreader_warnings=warnings,
342
388
  altitude=altitude,
389
+ pitch=pitch,
390
+ roll=roll,
343
391
  )
344
392
 
345
393
 
@@ -429,7 +477,9 @@ def decodeLatLon(data: dict, group: str) -> Tuple[Optional[float], Optional[floa
429
477
  return (lat, lon, warnings)
430
478
 
431
479
 
432
- def decodeDateTimeOriginal(data: dict, datetimeField: str) -> Tuple[Optional[datetime.datetime], List[str]]:
480
+ def decodeDateTimeOriginal(
481
+ data: dict, datetimeField: str, lat: Optional[float] = None, lon: Optional[float] = None
482
+ ) -> Tuple[Optional[datetime.datetime], List[str]]:
433
483
  d = None
434
484
  warnings = []
435
485
 
@@ -442,7 +492,6 @@ def decodeDateTimeOriginal(data: dict, datetimeField: str) -> Tuple[Optional[dat
442
492
  secondsRaw, microsecondsRaw, msw = decodeSecondsAndMicroSeconds(
443
493
  timeRaw[2], data["Exif.Photo.SubSecTimeOriginal"] if isExifTagUsable(data, "Exif.Photo.SubSecTimeOriginal", float) else "0"
444
494
  )
445
- tz = decodeTimeOffset(data, f"Exif.Photo.OffsetTime{'Original' if 'DateTimeOriginal' in datetimeField else ''}")
446
495
  warnings += msw
447
496
 
448
497
  d = datetime.datetime.combine(
@@ -452,9 +501,30 @@ def decodeDateTimeOriginal(data: dict, datetimeField: str) -> Tuple[Optional[dat
452
501
  minutesRaw,
453
502
  secondsRaw,
454
503
  microsecondsRaw,
455
- tzinfo=tz or datetime.timezone.utc,
456
504
  ),
457
505
  )
506
+
507
+ # Timezone handling
508
+ # Try to read from EXIF
509
+ tz = decodeTimeOffset(data, f"Exif.Photo.OffsetTime{'Original' if 'DateTimeOriginal' in datetimeField else ''}")
510
+ if tz is not None:
511
+ d = d.replace(tzinfo=tz)
512
+
513
+ # Otherwise, try to deduct from coordinates
514
+ elif lon is not None and lat is not None:
515
+ tz_name = tz_finder.timezone_at(lng=lon, lat=lat)
516
+ if tz_name is not None:
517
+ d = pytz.timezone(tz_name).localize(d)
518
+ # Otherwise, default to UTC + warning
519
+ else:
520
+ d = d.replace(tzinfo=datetime.timezone.utc)
521
+ warnings.append("Precise timezone information not found, fallback to UTC")
522
+
523
+ # Otherwise, default to UTC + warning
524
+ else:
525
+ d = d.replace(tzinfo=datetime.timezone.utc)
526
+ warnings.append("Precise timezone information not found (and no GPS coordinates to help), fallback to UTC")
527
+
458
528
  except ValueError as e:
459
529
  warnings.append("Skipping original date/time (from " + datetimeField + ") as it was not recognized:\n\t" + str(e))
460
530
 
@@ -467,7 +537,9 @@ def decodeTimeOffset(data: dict, offsetTimeField: str) -> Optional[datetime.tzin
467
537
  return None
468
538
 
469
539
 
470
- def decodeGPSDateTime(data: dict, group: str) -> Tuple[Optional[datetime.datetime], List[str]]:
540
+ def decodeGPSDateTime(
541
+ data: dict, group: str, lat: Optional[float] = None, lon: Optional[float] = None
542
+ ) -> Tuple[Optional[datetime.datetime], List[str]]:
471
543
  d = None
472
544
  warnings = []
473
545
 
@@ -504,6 +576,12 @@ def decodeGPSDateTime(data: dict, group: str) -> Tuple[Optional[datetime.datetim
504
576
  ),
505
577
  )
506
578
 
579
+ # Set timezone from coordinates
580
+ if lon is not None and lat is not None:
581
+ tz_name = tz_finder.timezone_at(lng=lon, lat=lat)
582
+ if tz_name is not None:
583
+ d = d.astimezone(pytz.timezone(tz_name))
584
+
507
585
  except ValueError as e:
508
586
  warnings.append(f"Skipping GPS date/time ({group} group) as it was not recognized:\n\t{str(e)}")
509
587
 
@@ -3,11 +3,11 @@ from datetime import datetime, timedelta
3
3
  from dataclasses import dataclass
4
4
  from geopic_tag_reader.model import PictureType
5
5
  from enum import Enum
6
+ import timezonefinder # type: ignore
7
+ import pytz
6
8
 
7
9
  try:
8
10
  import pyexiv2 # type: ignore
9
- import timezonefinder # type: ignore
10
- import pytz
11
11
  except ImportError:
12
12
  raise Exception(
13
13
  """Impossible to write the exif tags without the '[write-exif]' dependency (that will need to install libexiv2).
@@ -7,16 +7,23 @@ from .conftest import FIXTURE_DIR, openImg
7
7
  def assertGeoPicTagsEquals(gpt, expectedDict):
8
8
  assert gpt.lat == expectedDict.get("lat")
9
9
  assert gpt.lon == expectedDict.get("lon")
10
- assert gpt.ts == expectedDict.get("ts")
11
10
  assert gpt.heading == expectedDict.get("heading")
12
11
  assert gpt.type == expectedDict.get("type")
13
12
  assert gpt.make == expectedDict.get("make")
14
13
  assert gpt.model == expectedDict.get("model")
15
14
  assert gpt.focal_length == expectedDict.get("focal_length")
16
15
  assert gpt.altitude == expectedDict.get("altitude")
16
+ assert gpt.pitch == expectedDict.get("pitch")
17
+ assert gpt.roll == expectedDict.get("roll")
17
18
  assert gpt.tagreader_warnings == expectedDict.get("tagreader_warnings", [])
18
19
  assert len(gpt.exif) > 0
19
20
 
21
+ if expectedDict.get("ts") is not None:
22
+ assert gpt.ts is not None
23
+ assert gpt.ts.isoformat() == expectedDict["ts"]
24
+ else:
25
+ assert gpt.ts is None
26
+
20
27
  if gpt.crop:
21
28
  assert expectedDict.get("crop") is not None
22
29
  assert gpt.crop.fullWidth == expectedDict["crop"].get("fullWidth")
@@ -37,13 +44,15 @@ def test_readPictureMetadata(datafiles):
37
44
  {
38
45
  "lat": 49.00688961988304,
39
46
  "lon": 1.9191854417991367,
40
- "ts": 1627550214.0,
47
+ "ts": "2021-07-29T11:16:54+02:00",
41
48
  "heading": 349,
42
49
  "type": "equirectangular",
43
50
  "make": "GoPro",
44
51
  "model": "Max",
45
52
  "focal_length": 3,
46
53
  "altitude": 93,
54
+ "roll": 0,
55
+ "pitch": 0,
47
56
  },
48
57
  )
49
58
 
@@ -56,13 +65,15 @@ def test_readPictureMetadata_negCoords(datafiles):
56
65
  {
57
66
  "lat": 48.33756428166505,
58
67
  "lon": -1.9331088333333333,
59
- "ts": 1652453580.0,
68
+ "ts": "2022-05-13T16:53:00+02:00",
60
69
  "heading": 32,
61
70
  "type": "equirectangular",
62
71
  "make": "GoPro",
63
72
  "model": "Max",
64
73
  "focal_length": 3,
65
74
  "altitude": 79,
75
+ "roll": 0,
76
+ "pitch": 0,
66
77
  },
67
78
  )
68
79
 
@@ -75,7 +86,7 @@ def test_readPictureMetadata_flat(datafiles):
75
86
  {
76
87
  "lat": 48.139852239480945,
77
88
  "lon": -1.9499731060073981,
78
- "ts": 1429976268.0,
89
+ "ts": "2015-04-25T15:37:48+02:00",
79
90
  "heading": 155,
80
91
  "type": "flat",
81
92
  "make": "OLYMPUS IMAGING CORP.",
@@ -93,7 +104,7 @@ def test_readPictureMetadata_flat2(datafiles):
93
104
  {
94
105
  "lat": 48.85779642035038,
95
106
  "lon": 2.3392783047650747,
96
- "ts": 1430744932.0,
107
+ "ts": "2015-05-04T13:08:52+02:00",
97
108
  "heading": 302,
98
109
  "type": "flat",
99
110
  "make": "Canon",
@@ -111,7 +122,7 @@ def test_readPictureMetadata_xmpHeading(datafiles):
111
122
  {
112
123
  "lat": 50.87070833333333,
113
124
  "lon": -1.5260916666666666,
114
- "ts": 1600008019.767,
125
+ "ts": "2020-09-13T15:40:19.767000+01:00",
115
126
  "heading": 67,
116
127
  "type": "equirectangular",
117
128
  "make": "Google",
@@ -130,7 +141,7 @@ def test_readPictureMetadata_noHeading(datafiles):
130
141
  {
131
142
  "lat": 48.15506638888889,
132
143
  "lon": -1.6844680555555556,
133
- "ts": 1666166194.0,
144
+ "ts": "2022-10-19T09:56:34+02:00",
134
145
  "heading": None,
135
146
  "type": "flat",
136
147
  "make": "SONY",
@@ -154,8 +165,10 @@ def test_readPictureMetadata_ricoh_theta(datafiles):
154
165
  "lon": 2.3205357914890987,
155
166
  "make": "RICOH",
156
167
  "model": "THETA m15",
157
- "ts": 1458911533.0,
168
+ "ts": "2016-03-25T14:12:13+01:00",
158
169
  "type": "equirectangular",
170
+ "roll": 3,
171
+ "pitch": 1,
159
172
  },
160
173
  )
161
174
 
@@ -173,7 +186,7 @@ def test_readPictureMetadata_v4mpack(datafiles):
173
186
  "lon": -1.2761512389983616,
174
187
  "make": "STFMANI",
175
188
  "model": "V4MPOD 1",
176
- "ts": 1555417213.0,
189
+ "ts": "2019-04-16T14:20:13+02:00",
177
190
  "type": "equirectangular",
178
191
  "altitude": 34,
179
192
  },
@@ -192,7 +205,7 @@ def test_readPictureMetadata_a5000(datafiles):
192
205
  "lon": 2.51197323068765,
193
206
  "make": "OnePlus",
194
207
  "model": "ONEPLUS A5000",
195
- "ts": 1626797632.199995,
208
+ "ts": "2021-07-20T16:13:52.199995+02:00",
196
209
  "type": "flat",
197
210
  "altitude": 0,
198
211
  },
@@ -227,7 +240,7 @@ def test_readPictureMetadata_int_long_tag(datafiles):
227
240
  "lon": 4.700622,
228
241
  "make": None,
229
242
  "model": None,
230
- "ts": 1673515020.0,
243
+ "ts": "2023-01-12T09:17:00+01:00",
231
244
  "type": "flat",
232
245
  "tagreader_warnings": ["GPSLongitudeRef not found, assuming GPSLongitudeRef is East"],
233
246
  },
@@ -249,7 +262,7 @@ def test_readPictureMetadata_invalidGpsDate(datafiles):
249
262
  "lon": 2.358506155368721,
250
263
  "make": "samsung",
251
264
  "model": None,
252
- "ts": 1631101437.0754,
265
+ "ts": "2021-09-08T11:43:57.075400+02:00",
253
266
  "type": "flat",
254
267
  "altitude": 73,
255
268
  "tagreader_warnings": [
@@ -271,7 +284,7 @@ def test_readPictureMetadata_gpsDateStamp(datafiles):
271
284
  "lon": 2.4833194444444446,
272
285
  "make": "Apple",
273
286
  "model": "iPhone 12 Pro",
274
- "ts": 1682785851.565,
287
+ "ts": "2023-04-29T18:30:51.565000+02:00",
275
288
  "type": "flat",
276
289
  "altitude": 36,
277
290
  "tagreader_warnings": [
@@ -293,7 +306,7 @@ def test_readPictureMetadata_gpsDateString(datafiles):
293
306
  "lon": -1.0015555555555555,
294
307
  "make": "Apple",
295
308
  "model": "iPhone 8 Plus",
296
- "ts": 1564313142.529,
309
+ "ts": "2019-07-28T13:25:42.529000+02:00",
297
310
  "type": "flat",
298
311
  "altitude": 19,
299
312
  "tagreader_warnings": [
@@ -322,7 +335,7 @@ def test_readPictureMetadata_cropped(datafiles):
322
335
  "lon": 1.919185441804927,
323
336
  "make": "GoPro",
324
337
  "model": "Max",
325
- "ts": 1627550214.0,
338
+ "ts": "2021-07-29T11:16:54+02:00",
326
339
  "type": "equirectangular",
327
340
  "altitude": 93,
328
341
  "crop": {"fullWidth": 4032, "fullHeight": 2016, "width": 2150, "height": 1412, "top": 134, "left": 538},
@@ -341,7 +354,7 @@ def test_readPictureMetadata_datetimeoriginal(datafiles):
341
354
  "lon": -1.382302762883527,
342
355
  "make": "Motorola",
343
356
  "model": "XT1052",
344
- "ts": 1598866588.0,
357
+ "ts": "2020-08-31T09:36:28+02:00",
345
358
  "type": "flat",
346
359
  "altitude": 72,
347
360
  "tagreader_warnings": [
@@ -362,7 +375,7 @@ def test_readPictureMetadata_datetimeoriginal_decimal_milliseconds(datafiles):
362
375
  "lon": 121.15072633333334,
363
376
  "make": "BlackVue",
364
377
  "model": "DR900S-1CH",
365
- "ts": 1699748719.023,
378
+ "ts": "2023-11-12T00:25:19.023000+08:00",
366
379
  "type": "flat",
367
380
  "altitude": 29,
368
381
  "tagreader_warnings": [],
@@ -401,7 +414,7 @@ def test_readPictureWithoutCoord(datafiles):
401
414
  "lon": None,
402
415
  "make": "Apple",
403
416
  "model": "iPhone 12 Pro",
404
- "ts": 1682785851.565,
417
+ "ts": "2023-04-29T16:30:51.565000+00:00",
405
418
  "type": "flat",
406
419
  "altitude": 36,
407
420
  "tagreader_warnings": [
@@ -472,7 +485,7 @@ def test_readPictureWithLatitudeAsFloat(datafiles):
472
485
  "lon": 7.683081,
473
486
  "make": None,
474
487
  "model": "PULSAR",
475
- "ts": 1637075896.89,
488
+ "ts": "2021-11-16T16:18:16.890000+01:00",
476
489
  "type": "equirectangular",
477
490
  "altitude": None,
478
491
  "tagreader_warnings": [],
@@ -492,7 +505,7 @@ def test_readPictureMetadata_gps_date_time_stamp(datafiles):
492
505
  "lon": 7.683081,
493
506
  "make": None,
494
507
  "model": "PULSAR",
495
- "ts": 1637075896.89,
508
+ "ts": "2021-11-16T16:18:16.890000+01:00",
496
509
  "type": "equirectangular",
497
510
  "altitude": None,
498
511
  "tagreader_warnings": [],
@@ -512,7 +525,7 @@ def test_readPictureMetadata_insta360(datafiles):
512
525
  "lon": 3.482755555555556,
513
526
  "make": "Insta360",
514
527
  "model": "One X2.PHOTO_NORMAL",
515
- "ts": 1700662669.0,
528
+ "ts": "2023-11-22T14:17:49+01:00",
516
529
  "type": "equirectangular",
517
530
  "altitude": 84,
518
531
  "tagreader_warnings": [],
@@ -532,7 +545,7 @@ def test_readPictureMetadata_ricoh_noproj(datafiles):
532
545
  "lon": -1.5464416503906249,
533
546
  "make": "Ricoh",
534
547
  "model": "Theta S",
535
- "ts": 1672575342.0,
548
+ "ts": "2023-01-01T13:15:42+01:00",
536
549
  "type": "equirectangular",
537
550
  "altitude": None,
538
551
  "tagreader_warnings": [],
@@ -549,7 +562,7 @@ def test_readPictureMetadata_gopromax_flat(datafiles):
549
562
  {
550
563
  "lat": 47.22555109997222,
551
564
  "lon": -1.5631604999722222,
552
- "ts": 1708173992.0,
565
+ "ts": "2024-02-17T13:46:32+01:00",
553
566
  "heading": None,
554
567
  "type": "flat",
555
568
  "make": "GoPro",
@@ -572,7 +585,7 @@ def test_readPictureMetadata_broken_makernotes(datafiles):
572
585
  "lon": -1.7841980555555554,
573
586
  "make": "SONY",
574
587
  "model": "FDR-X1000V",
575
- "ts": 1599115820.0,
588
+ "ts": "2020-09-03T08:50:20+02:00",
576
589
  "type": "flat",
577
590
  "altitude": 99,
578
591
  "focal_length": 2.8,
@@ -590,13 +603,15 @@ def test_readPictureMetadata_datetime_offset(datafiles):
590
603
  {
591
604
  "lat": 48.33756428166505,
592
605
  "lon": -1.9331088333333333,
593
- "ts": 1652453651.0,
606
+ "ts": "2022-05-13T16:54:11+02:00",
594
607
  "heading": 32,
595
608
  "type": "equirectangular",
596
609
  "make": "GoPro",
597
610
  "model": "Max",
598
611
  "focal_length": 3,
599
612
  "altitude": 79,
613
+ "roll": 0,
614
+ "pitch": 0,
600
615
  },
601
616
  )
602
617
 
@@ -610,7 +625,7 @@ def test_readPictureMetadata_gps_date_slash(datafiles):
610
625
  {
611
626
  "lat": 43.42541569992266,
612
627
  "lon": 1.3766216000638112,
613
- "ts": 1686640500.0,
628
+ "ts": "2023-06-13T09:15:00+02:00",
614
629
  "heading": None,
615
630
  "type": "flat",
616
631
  "make": None,
@@ -623,3 +638,9 @@ def test_readPictureMetadata_gps_date_slash(datafiles):
623
638
  ],
624
639
  },
625
640
  )
641
+
642
+
643
+ @pytest.mark.datafiles(os.path.join(FIXTURE_DIR, "charset.jpg"))
644
+ def test_readPictureMetadata_charset(datafiles):
645
+ result = reader.readPictureMetadata(openImg(str(datafiles) + "/charset.jpg"))
646
+ assert result.exif["Exif.Photo.UserComment"] == "CD31 31_D0003_51_600"
@@ -18,7 +18,7 @@ def test_writePictureMetadata_capture_time(datafiles):
18
18
  image_file_upd = writer.writePictureMetadata(img_orig, writer.PictureMetadata(capture_time=capture_time))
19
19
  tags = reader.readPictureMetadata(image_file_upd)
20
20
 
21
- assert datetime.fromtimestamp(tags.ts, tz=pytz.UTC) == capture_time
21
+ assert tags.ts == capture_time
22
22
 
23
23
  # we also check specific tags:
24
24
  assert tags.exif["Exif.Photo.DateTimeOriginal"] == "2023-06-01 12:48:01"
@@ -36,7 +36,7 @@ def test_writePictureMetadata_capture_time_no_timezone(datafiles):
36
36
  tags = reader.readPictureMetadata(image_file_upd)
37
37
 
38
38
  paris = pytz.timezone("Europe/Paris")
39
- assert datetime.fromtimestamp(tags.ts, tz=pytz.UTC) == paris.localize(capture_time).astimezone(pytz.UTC)
39
+ assert tags.ts == paris.localize(capture_time).astimezone(pytz.UTC)
40
40
 
41
41
  # DateTimeOriginal should be a local time, so 12:48:01 localized in Europe/Paris timezome (since it's where the picture has been taken)
42
42
  assert tags.exif["Exif.Photo.DateTimeOriginal"] == "2023-06-01 12:48:01"
@@ -78,7 +78,7 @@ def test_writePictureMetadata_capture_time_no_position_in_file_but_overriden(dat
78
78
  tags = reader.readPictureMetadata(image_file_upd)
79
79
 
80
80
  paris = pytz.timezone("Europe/Paris")
81
- assert datetime.fromtimestamp(tags.ts, tz=pytz.UTC) == paris.localize(capture_time).astimezone(pytz.UTC)
81
+ assert tags.ts == paris.localize(capture_time).astimezone(pytz.UTC)
82
82
 
83
83
  # DateTimeOriginal should be a local time, so 12:48:01 localized in Europe/Paris timezome (since it's where the picture has been taken)
84
84
  assert tags.exif["Exif.Photo.DateTimeOriginal"] == "2023-06-01 12:48:01"
@@ -11,6 +11,9 @@ dependencies = [
11
11
  "typer ~= 0.12",
12
12
  "xmltodict ~= 0.13",
13
13
  "pyexiv2 == 2.8.3",
14
+ "timezonefinder == 6.2.0",
15
+ "pytz ~= 2023.3",
16
+ "types-pytz ~= 2023.3.0.1",
14
17
  ]
15
18
  requires-python = ">=3.8"
16
19
 
@@ -33,9 +36,6 @@ dev = [
33
36
  build = ["flit ~= 3.8.0"]
34
37
  # optional dependencies to be able to write exif tags
35
38
  write-exif = [
36
- "timezonefinder == 6.2.0",
37
- "pytz ~= 2023.3",
38
- "types-pytz ~= 2023.3.0.1",
39
39
  "python-dateutil ~= 2.8.2",
40
40
  ]
41
41