geopic-tag-reader 1.1.5__tar.gz → 1.3.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 (39) hide show
  1. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/.gitlab-ci.yml +14 -14
  2. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/CHANGELOG.md +29 -1
  3. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/Makefile +1 -1
  4. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/PKG-INFO +6 -6
  5. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/README.md +5 -5
  6. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/docs/index.md +6 -6
  7. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/docs/tech/api_reference.md +4 -0
  8. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/__init__.py +1 -1
  9. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/main.py +5 -1
  10. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/reader.py +100 -70
  11. geopic_tag_reader-1.3.0/geopic_tag_reader/sequence.py +342 -0
  12. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  13. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.po +169 -0
  14. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  15. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.po +42 -30
  16. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  17. geopic_tag_reader-1.1.5/geopic_tag_reader/translations/geopic_tag_reader.pot → geopic_tag_reader-1.3.0/geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.po +4 -5
  18. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  19. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.po +146 -0
  20. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  21. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.po +5 -1
  22. geopic_tag_reader-1.3.0/geopic_tag_reader/translations/geopic_tag_reader.pot +159 -0
  23. geopic_tag_reader-1.1.5/geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  24. geopic_tag_reader-1.1.5/geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  25. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/.gitignore +0 -0
  26. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/.pre-commit-config.yaml +0 -0
  27. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/CODE_OF_CONDUCT.md +0 -0
  28. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/LICENSE +0 -0
  29. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/docs/develop.md +0 -0
  30. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/docs/install.md +0 -0
  31. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/docs/tech/cli.md +0 -0
  32. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/camera.py +0 -0
  33. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/i18n.py +0 -0
  34. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/model.py +0 -0
  35. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/py.typed +0 -0
  36. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/geopic_tag_reader/writer.py +0 -0
  37. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/mkdocs.yml +0 -0
  38. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/pyproject.toml +0 -0
  39. {geopic_tag_reader-1.1.5 → geopic_tag_reader-1.3.0}/pytest.ini +0 -0
@@ -38,20 +38,20 @@ tests-writer:
38
38
  - make i18n-po2code
39
39
  - pytest -s -vv
40
40
 
41
- tests-deploy_pypi:
42
- stage: deploy
43
- image: python:3.8
44
- variables:
45
- FLIT_INDEX_URL: https://test.pypi.org/legacy/
46
- FLIT_USERNAME: $TEST_FLIT_USERNAME
47
- FLIT_PASSWORD: $TEST_FLIT_PASSWORD
48
- script:
49
- - apt update && apt install -y gcc git gettext
50
- - pip install .[build]
51
- - make i18n-po2code
52
- - flit publish
53
- only:
54
- - develop
41
+ # tests-deploy_pypi:
42
+ # stage: deploy
43
+ # image: python:3.8
44
+ # variables:
45
+ # FLIT_INDEX_URL: https://test.pypi.org/legacy/
46
+ # FLIT_USERNAME: $TEST_FLIT_USERNAME
47
+ # FLIT_PASSWORD: $TEST_FLIT_PASSWORD
48
+ # script:
49
+ # - apt update && apt install -y gcc git gettext
50
+ # - pip install .[build]
51
+ # - make i18n-po2code
52
+ # - flit publish
53
+ # only:
54
+ # - develop
55
55
 
56
56
  deploy_pypi:
57
57
  stage: deploy
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2024-09-30
11
+
12
+ ### Changed
13
+
14
+ - Reader offers a new property `ts_by_source` to distinguish read datetime from GPS and camera.
15
+ - Sequence sorting uses same timestamp source through all pictures (GPS if available, camera else, fallback with other value if two timestamps are identical).
16
+ - Sequence splits uses whenever possible same timestamp source.
17
+
18
+ ### Fixed
19
+
20
+ - Documentation links were not up-to-date in README file.
21
+ - Sub-seconds values and time offset are applied only if part of the same EXIF group.
22
+
23
+ ## [1.2.0] - 2024-07-30
24
+
25
+ ### Added
26
+
27
+ - A new module `sequence` is available through Python to dispatch a set of pictures (based on their metadata) into several sequences, based on de-duplicate and split parameters. This is based on existing code previously stored in [command-line client](https://gitlab.com/panoramax/clients/cli), moved here to be shared between API and CLI.
28
+
29
+ ### Changed
30
+
31
+ - Reader offers a `yaw` value (360° sphere correction), distinct from `heading` (GPS direction). EXIF tags are read a bit differently to reflect this : `yaw` comes from `Xmp.Camera.Yaw & Xmp.GPano.PoseHeadingDegrees`, `heading` from `Exif.GPSInfo.GPSImgDirection & MAPCompassHeading`.
32
+
33
+ ### Removed
34
+ - Previously used values `Exif.GPSInfo.GPS(Pitch|Roll)` and `Xmp.GPano.InitialView(Pitch|Roll)Degrees` are dropped, first one for not being standard, second one for not being correct to use for pitch/roll (prefer `Xmp.GPano.Pose(Pitch|Roll)Degrees`).
35
+
10
36
  ## [1.1.5] - 2024-07-10
11
37
 
12
38
  ### Fixed
@@ -199,7 +225,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
199
225
 
200
226
  - EXIF tag reading methods extracted from [GeoVisio API](https://gitlab.com/panoramax/server/api)
201
227
 
202
- [Unreleased]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.1.5...main
228
+ [Unreleased]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.3.0...main
229
+ [1.3.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.2.0...1.3.0
230
+ [1.2.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.1.5...1.2.0
203
231
  [1.1.5]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.1.4...1.1.5
204
232
  [1.1.4]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.1.3...1.1.4
205
233
  [1.1.3]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.1.2...1.1.3
@@ -11,7 +11,7 @@ type-check: ## Check all python types
11
11
  mypy geopic_tag_reader/
12
12
 
13
13
  fmt: ## Format code
14
- black --fast .
14
+ python -m black --fast .
15
15
 
16
16
  ci: type-check fmt test ## Run all check like the ci
17
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geopic-tag-reader
3
- Version: 1.1.5
3
+ Version: 1.3.0
4
4
  Summary: GeoPicTagReader
5
5
  Author-email: Adrien PAVIE <panieravide@riseup.net>
6
6
  Requires-Python: >=3.8
@@ -31,7 +31,7 @@ Provides-Extra: write-exif
31
31
 
32
32
  # ![Panoramax](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Panoramax.svg/40px-Panoramax.svg.png) Panoramax
33
33
 
34
- __Panoramax__ is a digital resource for sharing and exploiting 📍📷 field photos. Anyone can take photographs of places visible from the public streets and contribute them to the Panoramax database. This data is then freely accessible and reusable by all. More information available at [gitlab.com/panoramax](https://gitlab.com/panoramax) and [panoramax.fr](https://panoramax.fr/).
34
+ __Panoramax__ is a digital resource for sharing and using 📍📷 field photos. Anyone can take photographs of places visible from the public streets and contribute them to the Panoramax database. This data is then freely accessible and reusable by all. More information available at [gitlab.com/panoramax](https://gitlab.com/panoramax) and [panoramax.fr](https://panoramax.fr/).
35
35
 
36
36
 
37
37
  # 📷 GeoPic Tag Reader
@@ -56,7 +56,7 @@ pip install geopic_tag_reader
56
56
  geopic-tag-reader --help
57
57
  ```
58
58
 
59
- To know more about install and other options, see [install documentation](./docs/Install.md).
59
+ To know more about install and other options, see [install documentation](./docs/install.md).
60
60
 
61
61
  If at some point you're lost or need help, you can contact us through [issues](https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/issues) or by [email](mailto:panieravide@riseup.net).
62
62
 
@@ -88,7 +88,7 @@ geopic-tag-reader write \
88
88
  --output /path/to/edited_image.jpg
89
89
  ```
90
90
 
91
- [Full documentation is also available here](./docs/CLI_USAGE.md).
91
+ [Full documentation is also available here](./docs/index.md).
92
92
 
93
93
  ### As Python library
94
94
 
@@ -119,14 +119,14 @@ editedImg.write(editedImgBytes)
119
119
  editedImg.close()
120
120
  ```
121
121
 
122
- [Full documentation is also available here](./docs/API_USAGE.md).
122
+ [Full documentation is also available here](./docs/tech/api_reference.md).
123
123
 
124
124
 
125
125
  ## Contributing
126
126
 
127
127
  Pull requests are welcome. For major changes, please open an [issue](https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/issues) first to discuss what you would like to change.
128
128
 
129
- More information about developing is available in [documentation](./docs/Develop.md).
129
+ More information about developing is available in [documentation](./docs/develop.md).
130
130
 
131
131
 
132
132
  ## ⚖️ License
@@ -1,6 +1,6 @@
1
1
  # ![Panoramax](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Panoramax.svg/40px-Panoramax.svg.png) Panoramax
2
2
 
3
- __Panoramax__ is a digital resource for sharing and exploiting 📍📷 field photos. Anyone can take photographs of places visible from the public streets and contribute them to the Panoramax database. This data is then freely accessible and reusable by all. More information available at [gitlab.com/panoramax](https://gitlab.com/panoramax) and [panoramax.fr](https://panoramax.fr/).
3
+ __Panoramax__ is a digital resource for sharing and using 📍📷 field photos. Anyone can take photographs of places visible from the public streets and contribute them to the Panoramax database. This data is then freely accessible and reusable by all. More information available at [gitlab.com/panoramax](https://gitlab.com/panoramax) and [panoramax.fr](https://panoramax.fr/).
4
4
 
5
5
 
6
6
  # 📷 GeoPic Tag Reader
@@ -25,7 +25,7 @@ pip install geopic_tag_reader
25
25
  geopic-tag-reader --help
26
26
  ```
27
27
 
28
- To know more about install and other options, see [install documentation](./docs/Install.md).
28
+ To know more about install and other options, see [install documentation](./docs/install.md).
29
29
 
30
30
  If at some point you're lost or need help, you can contact us through [issues](https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/issues) or by [email](mailto:panieravide@riseup.net).
31
31
 
@@ -57,7 +57,7 @@ geopic-tag-reader write \
57
57
  --output /path/to/edited_image.jpg
58
58
  ```
59
59
 
60
- [Full documentation is also available here](./docs/CLI_USAGE.md).
60
+ [Full documentation is also available here](./docs/index.md).
61
61
 
62
62
  ### As Python library
63
63
 
@@ -88,14 +88,14 @@ editedImg.write(editedImgBytes)
88
88
  editedImg.close()
89
89
  ```
90
90
 
91
- [Full documentation is also available here](./docs/API_USAGE.md).
91
+ [Full documentation is also available here](./docs/tech/api_reference.md).
92
92
 
93
93
 
94
94
  ## Contributing
95
95
 
96
96
  Pull requests are welcome. For major changes, please open an [issue](https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/issues) first to discuss what you would like to change.
97
97
 
98
- More information about developing is available in [documentation](./docs/Develop.md).
98
+ More information about developing is available in [documentation](./docs/develop.md).
99
99
 
100
100
 
101
101
  ## ⚖️ License
@@ -50,7 +50,6 @@ The following EXIF tags are recognized and used if defined, but are **optional**
50
50
  Image orientation is read from
51
51
 
52
52
  - `GPSImgDirection`
53
- - `GPano:PoseHeadingDegrees`
54
53
  - or in [Mapillary](https://www.mapillary.com/) tags: `MAPCompassHeading`
55
54
 
56
55
  #### :material-timer: Milliseconds in date
@@ -80,21 +79,22 @@ Camera focal length (to get precise field of view) is read from:
80
79
  - `Exif.Image.FocalLength`
81
80
  - `Exif.Photo.FocalLength`
82
81
 
83
- #### :octicons-horizontal-rule-16: Pitch and roll
82
+ #### :octicons-horizontal-rule-16: Yaw, Pitch and Roll
83
+
84
+ Yaw value is read from:
85
+
86
+ - `Xmp.Camera.Yaw`
87
+ - `Xmp.GPano.PoseHeadingDegrees`
84
88
 
85
89
  Pitch value is read from:
86
90
 
87
91
  - `Xmp.Camera.Pitch`
88
- - `Exif.GPSInfo.GPSPitch`
89
92
  - `Xmp.GPano.PosePitchDegrees`
90
- - `Xmp.GPano.InitialViewPitchDegrees`
91
93
 
92
94
  Roll value is read from:
93
95
 
94
96
  - `Xmp.Camera.Roll`
95
- - `Exif.GPSInfo.GPSRoll`
96
97
  - `Xmp.GPano.PoseRollDegrees`
97
- - `Xmp.GPano.InitialViewRollDegrees`
98
98
 
99
99
  #### ⛰️ Altitude
100
100
 
@@ -16,6 +16,10 @@
16
16
 
17
17
  ::: geopic_tag_reader.writer
18
18
 
19
+ ## Sequence
20
+
21
+ ::: geopic_tag_reader.sequence
22
+
19
23
  ## Camera
20
24
 
21
25
  ::: geopic_tag_reader.camera
@@ -2,4 +2,4 @@
2
2
  GeoPicTagReader
3
3
  """
4
4
 
5
- __version__ = "1.1.5"
5
+ __version__ = "1.3.0"
@@ -25,7 +25,10 @@ def read(
25
25
  _ = i18n_init(lang)
26
26
  print(_("Latitude:"), metadata.lat)
27
27
  print(_("Longitude:"), metadata.lon)
28
- print(_("Timestamp:"), metadata.ts.isoformat())
28
+ print(_("Timestamp:"), metadata.ts)
29
+ if metadata.ts_by_source is not None:
30
+ print(" -", (metadata.ts_by_source.gps or _("not set")), _("(GPS)"))
31
+ print(" -", (metadata.ts_by_source.camera or _("not set")), _("(Camera)"))
29
32
  print(_("Heading:"), metadata.heading)
30
33
  print(_("Type:"), metadata.type)
31
34
  print(_("Make:"), metadata.make)
@@ -34,6 +37,7 @@ def read(
34
37
  print(_("Crop parameters:"), metadata.crop)
35
38
  print(_("Pitch:"), metadata.pitch)
36
39
  print(_("Roll:"), metadata.roll)
40
+ print(_("Yaw:"), metadata.yaw)
37
41
 
38
42
  if len(metadata.tagreader_warnings) > 0:
39
43
  print(_("Warnings raised by reader:"))
@@ -39,6 +39,32 @@ class CropValues:
39
39
  top: int
40
40
 
41
41
 
42
+ @dataclass
43
+ class TimeBySource:
44
+ """All datetimes read from available sources
45
+
46
+ Attributes:
47
+ gps (datetime): Time read from GPS clock
48
+ camera (datetime): Time read from camera clock (DateTimeOriginal)
49
+ """
50
+
51
+ gps: Optional[datetime.datetime] = None
52
+ camera: Optional[datetime.datetime] = None
53
+
54
+ def getBest(self) -> Optional[datetime.datetime]:
55
+ """Get the best available datetime to use"""
56
+ if self.gps is not None and self.camera is None:
57
+ return self.gps
58
+ elif self.gps is None and self.camera is not None:
59
+ return self.camera
60
+ elif self.gps is None and self.camera is None:
61
+ return None
62
+ elif self.camera.microsecond > 0 and self.gps.microsecond == 0: # type: ignore
63
+ return self.camera
64
+ else:
65
+ return self.gps
66
+
67
+
42
68
  @dataclass
43
69
  class GeoPicTags:
44
70
  """Tags associated to a geolocated picture
@@ -47,7 +73,7 @@ class GeoPicTags:
47
73
  lat (float): GPS Latitude (in WGS84)
48
74
  lon (float): GPS Longitude (in WGS84)
49
75
  ts (datetime): The capture date (date & time with timezone)
50
- heading (int): Picture heading/yaw (in degrees, North = 0°, East = 90°, South = 180°, West = 270°)
76
+ heading (int): Picture GPS heading (in degrees, North = 0°, East = 90°, South = 180°, West = 270°). Value is computed based on image center (if yaw=0°)
51
77
  type (str): The kind of picture (flat, equirectangular)
52
78
  make (str): The camera manufacturer name
53
79
  model (str): The camera model name
@@ -58,6 +84,8 @@ class GeoPicTags:
58
84
  altitude (float): altitude (in m) (optional)
59
85
  pitch (float): Picture pitch angle, compared to horizon (in degrees, bottom = -90°, horizon = 0°, top = 90°)
60
86
  roll (float): Picture roll angle, on a right/left axis (in degrees, left-arm down = -90°, flat = 0°, right-arm down = 90°)
87
+ yaw (float): Picture yaw angle, on a vertical axis (in degrees, front = 0°, right = 90°, rear = 180°, left = 270°). This offsets the center image from GPS direction for a correct 360° sphere correction
88
+ ts_by_source (TimeBySource): all read timestamps from image, for finer processing.
61
89
 
62
90
 
63
91
  Implementation note: this needs to be sync with the PartialGeoPicTags structure
@@ -77,6 +105,8 @@ class GeoPicTags:
77
105
  altitude: Optional[float] = None
78
106
  pitch: Optional[float] = None
79
107
  roll: Optional[float] = None
108
+ yaw: Optional[float] = None
109
+ ts_by_source: Optional[TimeBySource] = None
80
110
 
81
111
 
82
112
  class InvalidExifException(Exception):
@@ -111,6 +141,8 @@ class PartialGeoPicTags:
111
141
  altitude: Optional[float] = None
112
142
  pitch: Optional[float] = None
113
143
  roll: Optional[float] = None
144
+ yaw: Optional[float] = None
145
+ ts_by_source: Optional[TimeBySource] = None
114
146
 
115
147
 
116
148
  class PartialExifException(Exception):
@@ -184,35 +216,21 @@ def readPictureMetadata(picture: bytes, lang_code: str = "en") -> GeoPicTags:
184
216
  if lon is not None and (lon < -180 or lon > 180):
185
217
  raise InvalidExifException(_("Read longitude is out of WGS84 bounds (should be in [-180, 180])"))
186
218
 
187
- # Parse date/time
188
- d, llw = decodeGPSDateTime(data, "Exif.GPSInfo", _, lat, lon)
219
+ # Parse GPS date/time
220
+ gpsTs, llw = decodeGPSDateTime(data, "Exif.GPSInfo", _, lat, lon)
189
221
 
190
222
  if len(llw) > 0:
191
223
  warnings.extend(llw)
192
224
 
193
- if d is None:
194
- d, llw = decodeGPSDateTime(data, "Xmp.exif", _, lat, lon)
225
+ if gpsTs is None:
226
+ gpsTs, llw = decodeGPSDateTime(data, "Xmp.exif", _, lat, lon)
195
227
  if len(llw) > 0:
196
228
  warnings.extend(llw)
197
229
 
198
- for exifField in [
199
- "Exif.Image.DateTimeOriginal",
200
- "Exif.Photo.DateTimeOriginal",
201
- "Exif.Image.DateTime",
202
- "Xmp.GPano.SourceImageCreateTime",
203
- ]:
204
- if d is None:
205
- d, llw = decodeDateTimeOriginal(data, exifField, _, lat, lon)
206
- if len(llw) > 0:
207
- warnings.extend(llw)
208
-
209
- if d is not None:
210
- break
211
-
212
- if d is None and isExifTagUsable(data, "MAPGpsTime"):
230
+ if gpsTs is None and isExifTagUsable(data, "MAPGpsTime"):
213
231
  try:
214
232
  year, month, day, hour, minutes, seconds, milliseconds = [int(dp) for dp in data["MAPGpsTime"].split("_")]
215
- d = datetime.datetime(
233
+ gpsTs = datetime.datetime(
216
234
  year,
217
235
  month,
218
236
  day,
@@ -226,57 +244,59 @@ def readPictureMetadata(picture: bytes, lang_code: str = "en") -> GeoPicTags:
226
244
  except Exception as e:
227
245
  warnings.append(_("Skipping Mapillary date/time as it was not recognized: {v}").format(v=data["MAPGpsTime"]))
228
246
 
229
- # Heading/Yaw
230
- heading = None
231
- if isExifTagUsable(data, "Xmp.GPano.PoseHeadingDegrees", float) and isExifTagUsable(data, "Exif.GPSInfo.GPSImgDirection", Fraction):
232
- gpsDir = int(round(float(Fraction(data["Exif.GPSInfo.GPSImgDirection"]))))
233
- gpanoHeading = int(round(float(data["Xmp.GPano.PoseHeadingDegrees"])))
234
- if gpsDir > 0 and gpanoHeading == 0:
235
- heading = gpsDir
236
- elif gpsDir == 0 and gpanoHeading > 0:
237
- heading = gpanoHeading
238
- else:
239
- if gpsDir != gpanoHeading:
240
- warnings.append(_("Contradicting heading values found, GPSImgDirection value is used"))
241
- heading = gpsDir
247
+ # Parse camera date/time
248
+ cameraTs = None
249
+ for exifGroup, dtField, subsecField in [
250
+ ("Exif.Photo", "DateTimeOriginal", "SubSecTimeOriginal"),
251
+ ("Exif.Image", "DateTimeOriginal", "SubSecTimeOriginal"),
252
+ ("Exif.Image", "DateTime", "SubSecTimeOriginal"),
253
+ ("Xmp.GPano", "SourceImageCreateTime", "SubSecTimeOriginal"),
254
+ ("Xmp.exif", "DateTimeOriginal", "SubsecTimeOriginal"), # Case matters
255
+ ]:
256
+ if cameraTs is None:
257
+ cameraTs, llw = decodeDateTimeOriginal(data, exifGroup, dtField, subsecField, _, lat, lon)
258
+ if len(llw) > 0:
259
+ warnings.extend(llw)
242
260
 
243
- elif isExifTagUsable(data, "Xmp.GPano.PoseHeadingDegrees", float):
244
- heading = int(round(float(data["Xmp.GPano.PoseHeadingDegrees"])))
261
+ if cameraTs is not None:
262
+ break
263
+ tsSources = TimeBySource(gps=gpsTs, camera=cameraTs) if gpsTs or cameraTs else None
264
+ d = tsSources.getBest() if tsSources is not None else None
245
265
 
246
- elif isExifTagUsable(data, "Exif.GPSInfo.GPSImgDirection", Fraction):
266
+ # GPS Heading
267
+ heading = None
268
+ if isExifTagUsable(data, "Exif.GPSInfo.GPSImgDirection", Fraction):
247
269
  heading = int(round(float(Fraction(data["Exif.GPSInfo.GPSImgDirection"]))))
248
270
 
249
271
  elif "MAPCompassHeading" in data and isExifTagUsable(data["MAPCompassHeading"], "TrueHeading", float):
250
272
  heading = int(round(float(data["MAPCompassHeading"]["TrueHeading"])))
251
273
 
252
- # Pitch & roll
274
+ # Yaw / Pitch / roll
275
+ yaw = None
253
276
  pitch = None
254
277
  roll = None
255
- exifPRFields = ["Xmp.Camera.$$", "Exif.GPSInfo.GPS$$", "Xmp.GPano.Pose$$Degrees", "Xmp.GPano.InitialView$$Degrees"]
256
- # For each potential EXIF field
257
- for exifField in exifPRFields:
258
- # Try out both Pitch & Roll variants
259
- for checkField in ["Pitch", "Roll"]:
260
- exifCheckField = exifField.replace("$$", checkField)
278
+ exifYPRFields = {
279
+ "yaw": ["Xmp.Camera.Yaw", "Xmp.GPano.PoseHeadingDegrees"],
280
+ "pitch": ["Xmp.Camera.Pitch", "Xmp.GPano.PosePitchDegrees"],
281
+ "roll": ["Xmp.Camera.Roll", "Xmp.GPano.PoseRollDegrees"],
282
+ }
283
+ for ypr in exifYPRFields:
284
+ for exifTag in exifYPRFields[ypr]:
261
285
  foundValue = None
262
286
  # Look for float or fraction
263
- if isExifTagUsable(data, exifCheckField, float):
264
- foundValue = float(data[exifCheckField])
265
- elif isExifTagUsable(data, exifCheckField, Fraction):
266
- foundValue = float(Fraction(data[exifCheckField]))
287
+ if isExifTagUsable(data, exifTag, float):
288
+ foundValue = float(data[exifTag])
289
+ elif isExifTagUsable(data, exifTag, Fraction):
290
+ foundValue = float(Fraction(data[exifTag]))
267
291
 
268
- # Save to correct variable (if not already set)
292
+ # Save found value
269
293
  if foundValue is not None:
270
- if checkField == "Pitch":
271
- if pitch is None:
272
- pitch = foundValue
273
- else:
274
- continue
275
- elif checkField == "Roll":
276
- if roll is None:
277
- roll = foundValue
278
- else:
279
- continue
294
+ if ypr == "yaw" and yaw is None:
295
+ yaw = foundValue
296
+ elif ypr == "pitch" and pitch is None:
297
+ pitch = foundValue
298
+ elif ypr == "roll" and roll is None:
299
+ roll = foundValue
280
300
 
281
301
  # Make and model
282
302
  make = data.get("Exif.Image.Make") or data.get("MAPDeviceMake")
@@ -383,6 +403,8 @@ def readPictureMetadata(picture: bytes, lang_code: str = "en") -> GeoPicTags:
383
403
  altitude=altitude,
384
404
  pitch=pitch,
385
405
  roll=roll,
406
+ yaw=yaw,
407
+ ts_by_source=tsSources,
386
408
  ),
387
409
  )
388
410
 
@@ -402,6 +424,8 @@ def readPictureMetadata(picture: bytes, lang_code: str = "en") -> GeoPicTags:
402
424
  altitude=altitude,
403
425
  pitch=pitch,
404
426
  roll=roll,
427
+ yaw=yaw,
428
+ ts_by_source=tsSources,
405
429
  )
406
430
 
407
431
 
@@ -492,20 +516,28 @@ def decodeLatLon(data: dict, group: str, _: Callable[[str], str]) -> Tuple[Optio
492
516
 
493
517
 
494
518
  def decodeDateTimeOriginal(
495
- data: dict, datetimeField: str, _: Callable[[str], str], lat: Optional[float] = None, lon: Optional[float] = None
519
+ data: dict,
520
+ exifGroup: str,
521
+ datetimeField: str,
522
+ subsecField: str,
523
+ _: Callable[[str], str],
524
+ lat: Optional[float] = None,
525
+ lon: Optional[float] = None,
496
526
  ) -> Tuple[Optional[datetime.datetime], List[str]]:
497
527
  d = None
498
528
  warnings = []
529
+ dtField = f"{exifGroup}.{datetimeField}"
530
+ ssField = f"{exifGroup}.{subsecField}"
499
531
 
500
- if d is None and isExifTagUsable(data, datetimeField):
532
+ if d is None and isExifTagUsable(data, dtField):
501
533
  try:
502
- dateRaw = data[datetimeField][:10].replace(":", "-")
503
- timeRaw = data[datetimeField][11:].split(":")
534
+ dateRaw = data[dtField][:10].replace(":", "-")
535
+ timeRaw = data[dtField][11:].split(":")
504
536
  hourRaw = int(timeRaw[0])
505
537
  minutesRaw = int(timeRaw[1])
506
538
  secondsRaw, microsecondsRaw, msw = decodeSecondsAndMicroSeconds(
507
- timeRaw[2],
508
- data["Exif.Photo.SubSecTimeOriginal"] if isExifTagUsable(data, "Exif.Photo.SubSecTimeOriginal", float) else "0",
539
+ timeRaw[2] if len(timeRaw) >= 3 else "0",
540
+ data[ssField] if isExifTagUsable(data, ssField, float) else "0",
509
541
  _,
510
542
  )
511
543
  warnings += msw
@@ -522,7 +554,7 @@ def decodeDateTimeOriginal(
522
554
 
523
555
  # Timezone handling
524
556
  # Try to read from EXIF
525
- tz = decodeTimeOffset(data, f"Exif.Photo.OffsetTime{'Original' if 'DateTimeOriginal' in datetimeField else ''}")
557
+ tz = decodeTimeOffset(data, f"{exifGroup}.OffsetTime{'Original' if 'DateTimeOriginal' in dtField else ''}")
526
558
  if tz is not None:
527
559
  d = d.replace(tzinfo=tz)
528
560
 
@@ -543,9 +575,7 @@ def decodeDateTimeOriginal(
543
575
 
544
576
  except ValueError as e:
545
577
  warnings.append(
546
- _("Skipping original date/time (from {datefield}) as it was not recognized: {v}").format(
547
- datefield=datetimeField, v=data[datetimeField]
548
- )
578
+ _("Skipping original date/time (from {datefield}) as it was not recognized: {v}").format(datefield=dtField, v=data[dtField])
549
579
  )
550
580
 
551
581
  return (d, warnings)
@@ -583,7 +613,7 @@ def decodeGPSDateTime(
583
613
  if timeRaw:
584
614
  seconds, microseconds, msw = decodeSecondsAndMicroSeconds(
585
615
  str(float(timeRaw[2])),
586
- data["Exif.Photo.SubSecTimeOriginal"] if isExifTagUsable(data, "Exif.Photo.SubSecTimeOriginal", float) else "0",
616
+ "0", # No SubSecTimeOriginal, it's only for DateTimeOriginal
587
617
  _,
588
618
  )
589
619