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.
- geopic_tag_reader/__init__.py +1 -1
- geopic_tag_reader/main.py +5 -1
- geopic_tag_reader/reader.py +100 -70
- geopic_tag_reader/sequence.py +342 -0
- geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.mo +0 -0
- geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.po +169 -0
- geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.mo +0 -0
- geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.po +42 -30
- geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.mo +0 -0
- geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.po +146 -0
- geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.mo +0 -0
- geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.po +146 -0
- geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.mo +0 -0
- geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.po +5 -1
- geopic_tag_reader/translations/geopic_tag_reader.pot +41 -29
- {geopic_tag_reader-1.1.5.dist-info → geopic_tag_reader-1.3.0.dist-info}/METADATA +6 -6
- geopic_tag_reader-1.3.0.dist-info/RECORD +25 -0
- geopic_tag_reader-1.1.5.dist-info/RECORD +0 -18
- {geopic_tag_reader-1.1.5.dist-info → geopic_tag_reader-1.3.0.dist-info}/LICENSE +0 -0
- {geopic_tag_reader-1.1.5.dist-info → geopic_tag_reader-1.3.0.dist-info}/WHEEL +0 -0
- {geopic_tag_reader-1.1.5.dist-info → geopic_tag_reader-1.3.0.dist-info}/entry_points.txt +0 -0
geopic_tag_reader/__init__.py
CHANGED
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
|
|
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:"))
|
geopic_tag_reader/reader.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
274
|
+
# Yaw / Pitch / roll
|
|
275
|
+
yaw = None
|
|
253
276
|
pitch = None
|
|
254
277
|
roll = None
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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,
|
|
264
|
-
foundValue = float(data[
|
|
265
|
-
elif isExifTagUsable(data,
|
|
266
|
-
foundValue = float(Fraction(data[
|
|
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
|
|
292
|
+
# Save found value
|
|
269
293
|
if foundValue is not None:
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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,
|
|
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,
|
|
532
|
+
if d is None and isExifTagUsable(data, dtField):
|
|
501
533
|
try:
|
|
502
|
-
dateRaw = data[
|
|
503
|
-
timeRaw = data[
|
|
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[
|
|
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"
|
|
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
|
-
|
|
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)
|
|
Binary file
|