geopic-tag-reader 1.1.5__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,4 +2,4 @@
2
2
  GeoPicTagReader
3
3
  """
4
4
 
5
- __version__ = "1.1.5"
5
+ __version__ = "1.3.0"
geopic_tag_reader/main.py CHANGED
@@ -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
 
@@ -0,0 +1,342 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Optional, List, Tuple
4
+ from pathlib import PurePath
5
+ from geopic_tag_reader.reader import GeoPicTags
6
+ import datetime
7
+ import math
8
+
9
+
10
+ class SortMethod(str, Enum):
11
+ filename_asc = "filename-asc"
12
+ filename_desc = "filename-desc"
13
+ time_asc = "time-asc"
14
+ time_desc = "time-desc"
15
+
16
+
17
+ @dataclass
18
+ class MergeParams:
19
+ maxDistance: Optional[float] = None
20
+ maxRotationAngle: Optional[int] = None
21
+
22
+ def is_merge_needed(self):
23
+ # Only check max distance, as max rotation angle is only useful when dist is defined
24
+ return self.maxDistance is not None
25
+
26
+
27
+ @dataclass
28
+ class SplitParams:
29
+ maxDistance: Optional[int] = None
30
+ maxTime: Optional[int] = None
31
+
32
+ def is_split_needed(self):
33
+ return self.maxDistance is not None or self.maxTime is not None
34
+
35
+
36
+ @dataclass
37
+ class Picture:
38
+ filename: str
39
+ metadata: GeoPicTags
40
+
41
+ def distance_to(self, other) -> float:
42
+ """Computes distance in meters based on Haversine formula"""
43
+ R = 6371000
44
+ phi1 = math.radians(self.metadata.lat)
45
+ phi2 = math.radians(other.metadata.lat)
46
+ delta_phi = math.radians(other.metadata.lat - self.metadata.lat)
47
+ delta_lambda = math.radians(other.metadata.lon - self.metadata.lon)
48
+ a = math.sin(delta_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
49
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
50
+ distance = R * c
51
+ return distance
52
+
53
+
54
+ class SplitReason(str, Enum):
55
+ time = "time"
56
+ distance = "distance"
57
+
58
+
59
+ @dataclass
60
+ class Split:
61
+ prevPic: Picture
62
+ nextPic: Picture
63
+ reason: SplitReason
64
+
65
+
66
+ @dataclass
67
+ class Sequence:
68
+ pictures: List[Picture]
69
+
70
+ def from_ts(self) -> Optional[datetime.datetime]:
71
+ """Start date/time of this sequence"""
72
+
73
+ if len(self.pictures) == 0:
74
+ return None
75
+ return self.pictures[0].metadata.ts
76
+
77
+ def to_ts(self) -> Optional[datetime.datetime]:
78
+ """End date/time of this sequence"""
79
+
80
+ if len(self.pictures) == 0:
81
+ return None
82
+ return self.pictures[-1].metadata.ts
83
+
84
+ def delta_with(self, otherSeq) -> Optional[Tuple[datetime.timedelta, float]]:
85
+ """
86
+ Delta between the end of this sequence and the start of the other one.
87
+ Returns a tuple (timedelta, distance in meters)
88
+ """
89
+
90
+ if len(self.pictures) == 0 or len(otherSeq.pictures) == 0:
91
+ return None
92
+
93
+ return (otherSeq.from_ts() - self.to_ts(), otherSeq.pictures[0].distance_to(self.pictures[-1])) # type: ignore
94
+
95
+
96
+ @dataclass
97
+ class DispatchReport:
98
+ sequences: List[Sequence]
99
+ duplicate_pictures: Optional[List[Picture]] = None
100
+ sequences_splits: Optional[List[Split]] = None
101
+
102
+
103
+ def sort_pictures(pictures: List[Picture], method: Optional[SortMethod] = SortMethod.time_asc) -> List[Picture]:
104
+ """Sorts pictures according to given strategy
105
+
106
+ Parameters
107
+ ----------
108
+ pictures : Picture[]
109
+ List of pictures to sort
110
+ method : SortMethod
111
+ Sort logic to adopt (time-asc, time-desc, filename-asc, filename-desc)
112
+
113
+ Returns
114
+ -------
115
+ Picture[]
116
+ List of pictures, sorted
117
+ """
118
+
119
+ if method is None:
120
+ method = SortMethod.time_asc
121
+
122
+ if method not in [item.value for item in SortMethod]:
123
+ raise Exception("Invalid sort strategy: " + str(method))
124
+
125
+ # Get the sort logic
126
+ strat, order = method.split("-")
127
+
128
+ # Sort based on filename
129
+ if strat == "filename":
130
+ # Check if pictures can be sorted by numeric notation
131
+ hasNonNumber = False
132
+ for p in pictures:
133
+ try:
134
+ int(PurePath(p.filename or "").stem)
135
+ except:
136
+ hasNonNumber = True
137
+ break
138
+
139
+ def sort_fct(p):
140
+ return PurePath(p.filename or "").stem if hasNonNumber else int(PurePath(p.filename or "").stem)
141
+
142
+ pictures.sort(key=sort_fct)
143
+
144
+ # Sort based on picture ts
145
+ elif strat == "time":
146
+ # Check if all pictures have GPS ts set
147
+ missingGpsTs = next(
148
+ (p for p in pictures if p.metadata is None or p.metadata.ts_by_source is None or p.metadata.ts_by_source.gps is None), None
149
+ )
150
+ if missingGpsTs:
151
+ # Check if all pictures have camera ts set
152
+ missingCamTs = next(
153
+ (p for p in pictures if p.metadata is None or p.metadata.ts_by_source is None or p.metadata.ts_by_source.camera is None),
154
+ None,
155
+ )
156
+ # Sort by best ts available
157
+ if missingCamTs:
158
+ pictures.sort(key=lambda p: p.metadata.ts.isoformat() if p.metadata is not None else "0000-00-00T00:00:00Z")
159
+ # Sort by camera ts
160
+ else:
161
+ pictures.sort(
162
+ key=lambda p: (
163
+ p.metadata.ts_by_source.camera.isoformat(), # type: ignore
164
+ p.metadata.ts_by_source.gps.isoformat() if p.metadata.ts_by_source.gps else "0000-00-00T00:00:00Z", # type: ignore
165
+ )
166
+ )
167
+ # Sort by GPS ts
168
+ else:
169
+ pictures.sort(
170
+ key=lambda p: (
171
+ p.metadata.ts_by_source.gps.isoformat(), # type: ignore
172
+ p.metadata.ts_by_source.camera.isoformat() if p.metadata.ts_by_source.camera else "0000-00-00T00:00:00Z", # type: ignore
173
+ )
174
+ )
175
+
176
+ if order == "desc":
177
+ pictures.reverse()
178
+
179
+ return pictures
180
+
181
+
182
+ def find_duplicates(pictures: List[Picture], params: Optional[MergeParams] = None) -> Tuple[List[Picture], List[Picture]]:
183
+ """
184
+ Finds too similar pictures.
185
+ Note that input list should be properly sorted.
186
+
187
+ Parameters
188
+ ----------
189
+ pictures : list of sorted pictures to check
190
+ params : parameters used to consider two pictures as a duplicate
191
+
192
+ Returns
193
+ -------
194
+ (Non-duplicates pictures, Duplicates pictures)
195
+ """
196
+
197
+ if params is None or not params.is_merge_needed():
198
+ return (pictures, [])
199
+
200
+ nonDups: List[Picture] = []
201
+ dups: List[Picture] = []
202
+ lastNonDuplicatedPicId = 0
203
+
204
+ for i, currentPic in enumerate(pictures):
205
+ if i == 0:
206
+ nonDups.append(currentPic)
207
+ continue
208
+
209
+ prevPic = pictures[lastNonDuplicatedPicId]
210
+
211
+ if prevPic.metadata is None or currentPic.metadata is None:
212
+ nonDups.append(currentPic)
213
+ continue
214
+
215
+ # Compare distance
216
+ dist = prevPic.distance_to(currentPic)
217
+
218
+ if dist <= params.maxDistance: # type: ignore
219
+ # Compare angle (if available on both images)
220
+ if params.maxRotationAngle is not None and prevPic.metadata.heading is not None and currentPic.metadata.heading is not None:
221
+ deltaAngle = abs(currentPic.metadata.heading - prevPic.metadata.heading)
222
+
223
+ if deltaAngle <= params.maxRotationAngle:
224
+ dups.append(currentPic)
225
+ else:
226
+ lastNonDuplicatedPicId = i
227
+ nonDups.append(currentPic)
228
+ else:
229
+ dups.append(currentPic)
230
+ else:
231
+ lastNonDuplicatedPicId = i
232
+ nonDups.append(currentPic)
233
+
234
+ return (nonDups, dups)
235
+
236
+
237
+ def split_in_sequences(pictures: List[Picture], splitParams: Optional[SplitParams] = SplitParams()) -> Tuple[List[Sequence], List[Split]]:
238
+ """
239
+ Split a list of pictures into many sequences.
240
+ Note that this function expect pictures to be sorted and have their metadata set.
241
+
242
+ Parameters
243
+ ----------
244
+ pictures : Picture[]
245
+ List of pictures to check, sorted and with metadata defined
246
+ splitParams : SplitParams
247
+ The parameters to define deltas between two pictures
248
+
249
+ Returns
250
+ -------
251
+ List[Sequence]
252
+ List of pictures splitted into smaller sequences
253
+ """
254
+
255
+ # No split parameters given -> just return given pictures
256
+ if splitParams is None or not splitParams.is_split_needed():
257
+ return ([Sequence(pictures)], [])
258
+
259
+ sequences: List[Sequence] = []
260
+ splits: List[Split] = []
261
+ currentPicList: List[Picture] = []
262
+
263
+ for pic in pictures:
264
+ if len(currentPicList) == 0: # No checks for 1st pic
265
+ currentPicList.append(pic)
266
+ else:
267
+ lastPic = currentPicList[-1]
268
+
269
+ # Missing metadata -> skip
270
+ if lastPic.metadata is None or pic.metadata is None:
271
+ currentPicList.append(pic)
272
+ continue
273
+
274
+ # Time delta
275
+ timeDelta = lastPic.metadata.ts - pic.metadata.ts
276
+ if (
277
+ lastPic.metadata.ts_by_source
278
+ and lastPic.metadata.ts_by_source.gps
279
+ and pic.metadata.ts_by_source
280
+ and pic.metadata.ts_by_source.gps
281
+ ):
282
+ timeDelta = lastPic.metadata.ts_by_source.gps - pic.metadata.ts_by_source.gps
283
+ elif (
284
+ lastPic.metadata.ts_by_source
285
+ and lastPic.metadata.ts_by_source.camera
286
+ and pic.metadata.ts_by_source
287
+ and pic.metadata.ts_by_source.camera
288
+ ):
289
+ timeDelta = lastPic.metadata.ts_by_source.camera - pic.metadata.ts_by_source.camera
290
+ timeOutOfDelta = False if splitParams.maxTime is None else (abs(timeDelta)).total_seconds() > splitParams.maxTime
291
+
292
+ # Distance delta
293
+ distance = lastPic.distance_to(pic)
294
+ distanceOutOfDelta = False if splitParams.maxDistance is None else distance > splitParams.maxDistance
295
+
296
+ # One of deltas maxed -> create new sequence
297
+ if timeOutOfDelta or distanceOutOfDelta:
298
+ sequences.append(Sequence(currentPicList))
299
+ currentPicList = [pic]
300
+ splits.append(Split(lastPic, pic, SplitReason.time if timeOutOfDelta else SplitReason.distance))
301
+
302
+ # Otherwise, still in same sequence
303
+ else:
304
+ currentPicList.append(pic)
305
+
306
+ sequences.append(Sequence(currentPicList))
307
+
308
+ return (sequences, splits)
309
+
310
+
311
+ def dispatch_pictures(
312
+ pictures: List[Picture],
313
+ sortMethod: Optional[SortMethod] = None,
314
+ mergeParams: Optional[MergeParams] = None,
315
+ splitParams: Optional[SplitParams] = None,
316
+ ) -> DispatchReport:
317
+ """
318
+ Dispatches a set of pictures into many sequences.
319
+ This function both sorts, de-duplicates and splits in sequences all your pictures.
320
+
321
+ Parameters
322
+ ----------
323
+ pictures : set of pictures to dispatch
324
+ sortMethod : strategy for sorting pictures
325
+ mergeParams : conditions for considering two pictures as duplicates
326
+ splitParams : conditions for considering two sequences as distinct
327
+
328
+ Returns
329
+ -------
330
+ DispatchReport : clean sequences, duplicates pictures and split reasons
331
+ """
332
+
333
+ # Sort
334
+ myPics = sort_pictures(pictures, sortMethod)
335
+
336
+ # De-duplicate
337
+ (myPics, dupsPics) = find_duplicates(myPics, mergeParams)
338
+
339
+ # Split in sequences
340
+ (mySeqs, splits) = split_in_sequences(myPics, splitParams)
341
+
342
+ return DispatchReport(mySeqs, dupsPics if len(dupsPics) > 0 else None, splits if len(splits) > 0 else None)