File Coverage

blib/lib/Image/ExifTool/Geotag.pm
Criterion Covered Total %
statement 469 741 63.2
branch 309 638 48.4
condition 106 281 37.7
subroutine 12 15 80.0
pod 0 10 0.0
total 896 1685 53.1


line stmt bran cond sub pod time code
1             #------------------------------------------------------------------------------
2             # File: Geotag.pm
3             #
4             # Description: Geotagging utility routines
5             #
6             # Revisions: 2009/04/01 - P. Harvey Created
7             # 2009/09/27 - PH Added Geosync feature
8             # 2009/06/25 - PH Read Garmin TCX track logs
9             # 2009/09/11 - PH Read ITC GPS track logs
10             # 2012/01/08 - PH Extract orientation information from PTNTHPR
11             # 2012/05/08 - PH Read Winplus Beacon .TXT files
12             # 2015/05/30 - PH Read Bramor gEO log files
13             # 2016/07/13 - PH Added ability to geotag date/time only
14             # 2019/07/02 - PH Added ability to read IMU CSV files
15             # 2019/11/10 - PH Also write pitch to CameraElevationAngle
16             # 2020/12/01 - PH Added ability to read DJI CSV log files
17             # 2022/06/21 - PH Added ability to read Google Takeout JSON files
18             #
19             # References: 1) http://www.topografix.com/GPX/1/1/
20             # 2) http://www.gpsinformation.org/dale/nmea.htm#GSA
21             # 3) http://code.google.com/apis/kml/documentation/kmlreference.html
22             # 4) http://www.fai.org/gliding/system/files/tech_spec_gnss.pdf
23             #------------------------------------------------------------------------------
24              
25             package Image::ExifTool::Geotag;
26              
27 2     2   4743 use strict;
  2         5  
  2         79  
28 2     2   12 use vars qw($VERSION);
  2         10  
  2         107  
29 2     2   16 use Image::ExifTool qw(:Public);
  2         8  
  2         398  
30 2     2   501 use Image::ExifTool::GPS;
  2         5  
  2         21690  
31              
32             $VERSION = '1.68';
33              
34 10     10 0 25 sub JITTER() { return 2 } # maximum time jitter
35              
36             sub GetTime($);
37             sub SetGeoValues($$;$);
38             sub PrintFixTime($);
39             sub PrintFix($@);
40              
41             # XML tags that we recognize (keys are forced to lower case)
42             my %xmlTag = (
43             lat => 'lat', # GPX
44             latitude => 'lat', # Garmin
45             latitudedegrees => 'lat', # Garmin TCX
46             lon => 'lon', # GPX
47             longitude => 'lon', # Garmin
48             longitudedegrees => 'lon', # Garmin TCX
49             ele => 'alt', # GPX
50             elevation => 'alt', # PH
51             alt => 'alt', # PH
52             altitude => 'alt', # Garmin
53             altitudemeters => 'alt', # Garmin TCX
54             'time' => 'time', # GPX/Garmin
55             fix => 'fixtype', # GPX
56             hdop => 'hdop', # GPX
57             vdop => 'vdop', # GPX
58             pdop => 'pdop', # GPX
59             sat => 'nsats', # GPX
60             atemp => 'atemp', # GPX (Garmin 550t)
61             when => 'time', # KML
62             coordinates => 'coords', # KML
63             coord => 'coords', # KML, as written by Google Location History
64             begin => 'begin', # KML TimeSpan
65             end => 'time', # KML TimeSpan
66             course => 'dir', # (written by Arduino)
67             pitch => 'pitch', # (written by Arduino)
68             roll => 'roll', # (written by Arduino)
69             # XML containers (fix is reset at the opening tag of these properties)
70             wpt => '', # GPX
71             trkpt => '', # GPX
72             rtept => '', # GPX
73             trackpoint => '', # Garmin
74             placemark => '', # KML
75             );
76              
77             # fix information keys which must be interpolated around a circle
78             my %cyclical = (lon => 1, track => 1, dir => 1, roll => 1);
79              
80             # fix information keys for each of our general categories
81             my %fixInfoKeys = (
82             'pos' => [ 'lat', 'lon' ],
83             track => [ 'track', 'speed' ],
84             alt => [ 'alt' ],
85             orient => [ 'dir', 'pitch', 'roll' ],
86             atemp => [ 'atemp' ],
87             );
88              
89             my %isOrient = ( dir => 1, pitch => 1, roll => 1 ); # test for orientation key
90              
91             # conversion factors for GPSSpeed
92             my %speedConv = (
93             'K' => 1.852, # km/h per knot
94             'M' => 1.150779448, # mph per knot
95             'k' => 'K', # (allow lower case)
96             'm' => 'M',
97             'km/h' => 'K', # (allow other formats)
98             'mph' => 'M',
99             );
100              
101             my $secPerDay = 24 * 3600; # a useful constant
102              
103             #------------------------------------------------------------------------------
104             # Load GPS track log file
105             # Inputs: 0) ExifTool ref, 1) track log data or file name
106             # Returns: geotag hash data reference or error string
107             # - the geotag hash has the following members:
108             # Points - hash of GPS fix information hashes keyed by Unix time
109             # Times - list of sorted Unix times (keys of Points hash)
110             # NoDate - flag if some points have no date (ie. referenced to 1970:01:01)
111             # IsDate - flag if some points have date
112             # Has - hash of flags for available information (track, orient, alt)
113             # - the fix information hash may contain:
114             # lat - signed latitude (required)
115             # lon - signed longitude (required)
116             # alt - signed altitude
117             # time - fix time in UTC as XML string
118             # fixtype- type of fix ('none'|'2d'|'3d'|'dgps'|'pps')
119             # pdop - dilution of precision
120             # hdop - horizontal DOP
121             # vdop - vertical DOP
122             # sats - comma-separated list of active satellites
123             # nsats - number of active satellites
124             # track - track heading (deg true)
125             # dir - image direction (deg true)
126             # pitch - pitch angle (deg)
127             # roll - roll angle (deg)
128             # speed - speed (knots)
129             # first - flag set for first fix of track
130             # - concatenates new data with existing track data stored in ExifTool NEW_VALUE
131             # for the Geotag tag
132             sub LoadTrackLog($$;$)
133             {
134 9     9 0 56 local ($_, $/, *EXIFTOOL_TRKFILE);
135 9         26 my ($et, $val) = @_;
136 9         47 my ($raf, $from, $time, $isDate, $noDate, $noDateChanged, $lastDate, $dateFlarm);
137 9         0 my ($nmeaStart, $fixSecs, @fixTimes, $lastFix, %nmea, @csvHeadings, $sortFixes);
138 9         0 my ($canCut, $cutPDOP, $cutHDOP, $cutSats, $e0, $e1, @tmp, $trackFile, $trackTime);
139              
140 9 50       27 unless (eval { require Time::Local }) {
  9         60  
141 0         0 return 'Geotag feature requires Time::Local installed';
142             }
143             # add data to existing track
144 9   100     46 my $geotag = $et->GetNewValue('Geotag') || { };
145              
146             # initialize track points lookup
147 9         26 my $points = $$geotag{Points};
148 9 100       47 $points or $points = $$geotag{Points} = { };
149              
150             # get lookup for available information types
151 9         34 my $has = $$geotag{Has};
152 9 100       48 $has or $has = $$geotag{Has} = { 'pos' => 1 };
153              
154 9         27 my $format = '';
155             # is $val track log data?
156 9 50       67 if ($val =~ /^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
    50          
157 0         0 $format = 'XML';
158 0         0 $/ = '>'; # set input record separator to '>' for XML/GPX data
159             } elsif ($val =~ /(\x0d\x0a|\x0d|\x0a)/) {
160 0         0 $/ = $1;
161             } else {
162             # $val is track file name
163 9 100       46 if ($et->Open(\*EXIFTOOL_TRKFILE, $val)) {
    50          
164 8         31 $trackFile = $val;
165 8         70 $raf = new File::RandomAccess(\*EXIFTOOL_TRKFILE);
166 8 50       46 unless ($raf->Read($_, 256)) {
167 0         0 close EXIFTOOL_TRKFILE;
168 0         0 return "Empty track file '${val}'";
169             }
170             # look for XML or GPX header (might as well allow UTF-8 BOM)
171 8 100       119 if (/^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
    50          
172 3         8 $format = 'XML';
173 3         14 $/ = '>'; # set input record separator to '>' for XML/GPX data
174             } elsif (/(\x0d\x0a|\x0d|\x0a)/) {
175 5         27 $/ = $1;
176             } else {
177 0         0 close EXIFTOOL_TRKFILE;
178 0         0 return "Invalid track file '${val}'";
179             }
180 8         41 $raf->Seek(0,0);
181 8         37 $from = "file '${val}'";
182             } elsif ($val eq 'DATETIMEONLY') {
183 1         5 $$geotag{DateTimeOnly} = 1;
184 1         4 $$geotag{IsDate} = 1;
185 1         6 $et->VPrint(0, 'Geotagging date/time only');
186 1         32 return $geotag;
187             } else {
188 0         0 return "Error opening GPS file '${val}'";
189             }
190             }
191 8 50       41 unless ($from) {
192             # set up RAF for reading log file in memory
193 0         0 $raf = new File::RandomAccess(\$val);
194 0         0 $from = 'data';
195             }
196              
197             # initialize cuts
198 8         33 my $maxHDOP = $et->Options('GeoMaxHDOP');
199 8         31 my $maxPDOP = $et->Options('GeoMaxPDOP');
200 8         35 my $minSats = $et->Options('GeoMinSats');
201 8   33     56 my $isCut = $maxHDOP || $maxPDOP || $minSats;
202              
203 8         20 my $numPoints = 0;
204 8         16 my $skipped = 0;
205 8         13 my $lastSecs = 0;
206 8         20 my $fix = { };
207 8         18 my $csvDelim = $et->Options('CSVDelim');
208 8 50       30 $csvDelim = ',' unless defined $csvDelim;
209 8         20 my (@saveFix, $timeSpan);
210 8         17 for (;;) {
211 330 100       830 $raf->ReadLine($_) or last;
212             # determine file format
213 322 100       608 if (not $format) {
214 7         23 s/^\xef\xbb\xbf//; # remove leading BOM if it exists
215 7 50 33     203 if (/^<(\?xml|gpx)[\s>]/) { # look for XML or GPX header
    100 66        
    50 66        
    100 66        
    50          
    50          
    100          
    100          
    50          
216 0         0 $format = 'XML';
217             # check for NMEA sentence
218             # (must ONLY start with ones that have timestamps! eg. not GSA or PTNTHPR!)
219             } elsif (/^.*\$([A-Z]{2}(RMC|GGA|GLL|ZDA)|PMGNTRK),/) {
220 2         5 $format = 'NMEA';
221 2   66     14 $nmeaStart = $2 || $1; # save type of first sentence
222             } elsif (/^A(FLA|XSY|FIL)/) {
223             # (don't set format yet because we want to read HFDTE first)
224 0         0 $nmeaStart = 'B' ;
225 0         0 next;
226             } elsif (/^HFDTE(?:DATE:)?(\d{2})(\d{2})(\d{2})/) {
227 1 50       9 my $year = $3 + ($3 >= 70 ? 1900 : 2000);
228 1         8 $dateFlarm = Time::Local::timegm(0,0,0,$1,$2-1,$year);
229 1         32 $nmeaStart = 'B' ;
230 1         3 $format = 'IGC';
231 1         2 next;
232             } elsif ($nmeaStart and /^B/) {
233             # parse IGC fixes without a date
234 0         0 $format = 'IGC';
235             } elsif (/^TP,D,/) {
236 0         0 $format = 'Winplus';
237             } elsif (/^\s*\d+\s+.*\sypr\s*$/ and (@tmp=split) == 12) {
238 1         3 $format = 'Bramor';
239             } elsif (((/\b(GPS)?Date/i and /\b(GPS)?(Date)?Time/i) or /\bTime\(seconds\)/i) and /\Q$csvDelim/) {
240 1         5 chomp;
241 1         12 @csvHeadings = split /\Q$csvDelim/;
242 1         4 $format = 'CSV';
243             # convert recognized headings to our parameter names
244 1         4 foreach (@csvHeadings) {
245 5         8 my $param;
246 5         10 s/^GPS ?//; # remove leading "GPS" to simplify regex patterns
247 5 100 33     57 if (/^Time ?\(seconds\)$/i) { # DJI
    50          
    50          
    50          
    100          
    100          
    50          
    50          
    50          
    50          
248             # DJI CSV log files have a column "Time(seconds)" which is seconds since
249             # the start of the flight. The date/time is obtained from the file name.
250 1         3 $param = 'runtime';
251 1 50 33     13 if ($trackFile and $trackFile =~ /(\d{4})-(\d{2})-(\d{2})[^\/]+(\d{2})-(\d{2})-(\d{2})[^\/]*$/) {
252 1         9 $trackTime = Image::ExifTool::TimeLocal($6,$5,$4,$3,$2-1,$1);
253 1         6 my $utc = PrintFixTime($trackTime);
254 1         9 my $tzs = Image::ExifTool::TimeZoneString([$6,$5,$4,$3,$2-1,$1-1900],$trackTime);
255 1         9 $et->VPrint(2, " DJI start time: $utc (local timezone is $tzs)\n");
256             } else {
257 0         0 return 'Error getting start time from file name for DJI CSV track file';
258             }
259             } elsif (/^Date ?Time/i) { # ExifTool addition
260 0         0 $param = 'datetime';
261             } elsif (/^Date/i) {
262 0         0 $param = 'date';
263             } elsif (/^Time(?! ?\(text\))/i) { # (ignore DJI "Time(text)" column)
264 0         0 $param = 'time';
265             } elsif (/^(Pos)?Lat/i) {
266 1         3 $param = 'lat';
267 1 50       6 /ref$/i and $param .= 'ref';
268             } elsif (/^(Pos)?Lon/i) {
269 1         3 $param = 'lon';
270 1 50       5 /ref$/i and $param .= 'ref';
271             } elsif (/^(Pos)?Alt/i) {
272 0         0 $param = 'alt';
273             } elsif (/^(Angle)?(Heading|Track)/i) {
274 0         0 $param = 'track';
275             } elsif (/^(Angle)?Pitch/i or /^Camera ?Elevation ?Angle/i) {
276 0         0 $param = 'pitch';
277             } elsif (/^(Angle)?Roll/i) {
278 0         0 $param = 'roll';
279             }
280 5 100       11 if ($param) {
281 3         13 $et->VPrint(2, "CSV column '${_}' is $param\n");
282 3         7 $_ = $param;
283             } else {
284 2         11 $et->VPrint(2, "CSV column '${_}' ignored\n");
285 2         4 $_ = ''; # ignore this column
286             }
287             }
288 1         3 next;
289             } elsif (/"(timelineObjects|placeVisit|activitySegment|latitudeE7)":/) {
290             # Google Takeout JSON format
291 0         0 $format = 'JSON';
292 0         0 $sortFixes = 1; # (fixes are not all in order for this format)
293             } else {
294             # search only first 50 lines of file for a valid fix
295 2 50       9 last if ++$skipped > 50;
296 2         6 next;
297             }
298             }
299             #
300             # XML format (GPX, KML, Garmin XML/TCX etc)
301             #
302 318 100       819 if ($format eq 'XML') {
    50          
    100          
    100          
    50          
303 177         257 my ($arg, $tok, $td);
304 177         449 s/\s*=\s*(['"])\s*/=$1/g; # remove unnecessary white space in attributes
305             # Workaround for KML generated by Google Location History:
306             # lat/lon/alt are space-separated; we want commas.
307 177         331 s{(\S+)\s+(\S+)\s+(\S+)()}{$1,$2,$3$4};
308 177         410 foreach $arg (split) {
309             # parse attributes (eg. GPX 'lat' and 'lon')
310             # (note: ignore namespace prefixes if they exist)
311 237 100       618 if ($arg =~ /^(\w+:)?(\w+)=(['"])(.*?)\3/g) {
312 38         104 my $tag = $xmlTag{lc $2};
313 38 100       67 if ($tag) {
314 18         45 $$fix{$tag} = $4;
315 18 50       56 if ($isOrient{$tag}) {
    50          
    50          
316 0         0 $$has{orient} = 1;
317             } elsif ($tag eq 'alt') {
318             # validate altitude
319 0 0 0     0 undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
320 0 0       0 $$has{alt} = 1 if $$fix{alt}; # set "has altitude" flag if appropriate
321             } elsif ($tag eq 'atemp') {
322 0         0 $$has{atemp} = 1;
323             }
324             }
325             }
326             # loop through XML elements
327 237         898 while ($arg =~ m{([^<>]*)<(/)?(\w+:)?(\w+)(>|$)}g) {
328 170         493 my $tag = $xmlTag{$tok = lc $4};
329             # parse as a simple property if this element has a value
330 170 100 100     444 if (defined $tag and not $tag) {
331             # a containing property was opened or closed
332 24 100 33     313 if (not $2) {
    100 66        
333             # opened: start a new fix
334 12         36 $lastFix = $fix = { };
335 12         22 undef @saveFix;
336 12         32 next;
337             } elsif ($fix and $lastFix and %$fix) {
338             # closed: transfer additional tags from current fix
339 11         45 foreach (keys %$fix) {
340 44 50       83 $$lastFix{$_} = $$fix{$_} unless defined $$lastFix{$_};
341             }
342 11         28 undef $lastFix;
343             }
344             }
345 158 100       357 if (length $1) {
    50          
346 51 100       99 if ($tag) {
347 32 100       65 if ($tag eq 'coords') {
348             # save other fixes if there are more than one
349 3 0 33     20 if (defined $$fix{lon} and defined $$fix{lat} and defined $$fix{alt}) {
      33        
350 0         0 push @saveFix, [ @$fix{'lon','lat','alt'} ];
351             }
352             # read KML "Point" coordinates
353 3         17 @$fix{'lon','lat','alt'} = split ',', $1;
354             } else {
355 29         86 $$fix{$tag} = $1;
356 29 50       102 if ($isOrient{$tag}) {
    100          
    50          
357 0         0 $$has{orient} = 1;
358             } elsif ($tag eq 'alt') {
359             # validate altitude
360 11 50 33     57 undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
361 11 50       43 $$has{alt} = 1 if $$fix{alt}; # set "has altitude" flag if appropriate
362             } elsif ($tag eq 'atemp') {
363 0         0 $$has{atemp} = 1;
364             }
365             }
366             }
367 51         123 next;
368             } elsif ($tok eq 'td') {
369 0         0 $td = 1;
370             }
371             # validate and store GPS fix
372 107 100 100     427 next unless defined $$fix{lat} and defined $$fix{lon} and $$fix{'time'};
      100        
373 14 50 33     100 unless ($$fix{lat} =~ /^[+-]?\d+\.?\d*/ and $$fix{lon} =~ /^[+-]?\d+\.?\d*/) {
374 0 0       0 $e0 or $et->VPrint(0, "Coordinate format error in $from\n"), $e0 = 1;
375 0         0 next;
376             }
377 14 50       77 unless (defined($time = GetTime($$fix{'time'}))) {
378 0 0       0 $e1 or $et->VPrint(0, "Timestamp format error in $from\n"), $e1 = 1;
379 0         0 next;
380             }
381 14         22 $isDate = 1;
382 14 50 33     77 $canCut= 1 if defined $$fix{pdop} or defined $$fix{hdop} or defined $$fix{nsats};
      33        
383             # generate extra fixes assuming an equally spaced track
384 14 50       41 if ($$fix{begin}) {
385 0         0 my $begin = GetTime($$fix{begin});
386 0         0 undef $$fix{begin};
387 0 0 0     0 if (defined $begin and $begin < $time) {
388 0   0     0 $$fix{span} = $timeSpan = ($timeSpan || 0) + 1;
389 0         0 my $i;
390             # duplicate the fix if there is only one so we will have
391             # a fix and the start and end of the TimeSpan
392 0 0       0 @saveFix or push @saveFix, [ @$fix{'lon','lat','alt'} ];
393 0         0 for ($i=0; $i<@saveFix; ++$i) {
394 0         0 my $t = $begin + ($time - $begin) * ($i / scalar(@saveFix));
395 0         0 my %f;
396 0         0 @f{'lon','lat','alt'} = @{$saveFix[$i]};
  0         0  
397 0 0 0     0 $t += 0.001 if not $i and $$points{$t}; # (avoid dupicates)
398 0         0 $f{span} = $timeSpan;
399 0         0 $$points{$t} = \%f;
400 0         0 push @fixTimes, $t;
401             }
402             }
403             }
404 14         66 $$points{$time} = $fix;
405 14         31 push @fixTimes, $time; # save times of all fixes in order
406 14         23 $fix = { };
407 14         27 undef @saveFix;
408 14         39 ++$numPoints;
409             }
410             }
411             # last ditch check KML description for timestamp (assume it is UTC)
412 177 0 33     405 $$fix{'time'} = "$1T$2Z" if $td and not $$fix{'time'} and
      33        
413             /[\s>](\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(\.\d+)?)/;
414 177         248 next;
415             #
416             # Winplus Beacon text file
417             #
418             } elsif ($format eq 'Winplus') {
419             # TP,D, 44.933666667, -93.186555556, 10/26/2011, 19:07:28, 0
420             # latitude longitude date time
421 0 0       0 /^TP,D,\s*([-+]?\d+\.\d*),\s*([-+]?\d+\.\d*),\s*(\d+)\/(\d+)\/(\d{4}),\s*(\d+):(\d+):(\d+)/ or next;
422 0         0 $$fix{lat} = $1;
423 0         0 $$fix{lon} = $2;
424 0         0 $time = Time::Local::timegm($8,$7,$6,$4,$3-1,$5);
425 96         149 DoneFix: $isDate = 1;
426 96         530 $$points{$time} = $fix;
427 96         213 push @fixTimes, $time;
428 96         177 $fix = { };
429 96         159 ++$numPoints;
430 96         210 next;
431             #
432             # Bramor gEO log file
433             #
434             } elsif ($format eq 'Bramor') {
435             # 1 0015 18.723675 50.672752 149 169.31 22/04/2015 07:06:55 169.31 8.88 28.07 ypr
436             # ? index latitude longitude alt track date time dir pitch roll
437 7         29 my @parts = split ' ', $_;
438 7 100 66     28 next unless @parts == 12 and $parts[11] eq 'ypr';
439 4         12 my @d = split m{/}, $parts[6]; # date (dd/mm/YYYY)
440 4         12 my @t = split m{:}, $parts[7]; # time (HH:MM:SS)
441 4 50 33     16 next unless @d == 3 and @t == 3;
442 4         28 @$fix{qw(lat lon alt track dir pitch roll)} = @parts[2,3,4,5,8,9,10];
443             # (add the seconds afterwards in case some models have decimal seconds)
444 4         24 $time = Time::Local::timegm(0,$t[1],$t[0],$d[0],$d[1]-1,$d[2]) + $t[2];
445             # set necessary flags for extra available information
446 4         138 @$has{qw(alt track orient)} = (1,1,1);
447 4         106 goto DoneFix; # save this fix
448             } elsif ($format eq 'CSV') {
449 93         179 chomp;
450 93         476 my @vals = split /\Q$csvDelim/;
451             #
452             # CSV format output of GPS/IMU POS system
453             # Date* - date in DD/MM/YYYY format
454             # Time* - time in HH:MM:SS.SSS format
455             # [Pos]Lat* - latitude in decimal degrees
456             # [Pos]Lon* - longitude in decimal degrees
457             # [Pos]Alt* - altitude in m relative to sea level
458             # [Angle]Heading* - GPSTrack in degrees true
459             # [Angle]Pitch* - pitch angle in degrees
460             # [Angle]Roll* - roll angle in degrees
461             # (ExifTool enhancements allow for standard tag names or descriptions as the column headings,
462             # add support for time zones and flexible coordinates, and allow new DateTime and Shift columns)
463             #
464 93         197 my ($param, $date, $secs, %neg);
465 93         172 foreach $param (@csvHeadings) {
466 461         722 my $val = shift @vals;
467 461 100       840 last unless defined $val;
468 460 100       800 next unless $param;
469 276 50 100     1139 if ($param eq 'datetime') {
    50          
    50          
    100          
    50          
    50          
    50          
470 0     0   0 local $SIG{'__WARN__'} = sub { };
471 0         0 my $dateTime = $et->InverseDateTime($val);
472 0 0       0 if ($dateTime) {
473 0         0 $date = Image::ExifTool::GetUnixTime($val, 2);
474 0         0 $secs = 0;
475             }
476             } elsif ($param eq 'date') {
477 0 0       0 if ($val =~ m{^(\d{2})/(\d{2})/(\d{4})$}) {
    0          
478 0         0 $date = Time::Local::timegm(0,0,0,$1,$2-1,$3);
479             } elsif ($val =~ /(\d{4}).*?(\d{2}).*?(\d{2})/) {
480 0         0 $date = Time::Local::timegm(0,0,0,$3,$2-1,$1);
481             }
482             } elsif ($param eq 'time') {
483 0 0       0 if ($val =~ /^(\d{1,2}):(\d{2}):(\d{2}(\.\d+)?).*?(([-+])(\d{1,2}):?(\d{2}))?/) {
484 0         0 $secs = (($1 * 60) + $2) * 60 + $3;
485             # adjust for time zone if specified
486 0 0       0 $secs += ($7 * 60 + $8) * ($6 eq '-' ? 60 : -60) if $5;
    0          
487             }
488             } elsif ($param eq 'lat' or $param eq 'lon') {
489 184         439 $$fix{$param} = Image::ExifTool::GPS::ToDegrees($val, 1);
490             } elsif ($param eq 'latref') {
491 0 0       0 $neg{lat} = 1 if $val =~ /^S/i;
492             } elsif ($param eq 'lonref') {
493 0 0       0 $neg{lon} = 1 if $val =~ /^W/i;
494             } elsif ($param eq 'runtime') {
495 92         152 $date = $trackTime;
496 92         153 $secs = $val;
497             } else {
498 0         0 $$fix{$param} = $val;
499             }
500             }
501             # make coordinate negative according to reference direction if necessary
502 93         208 foreach $param (keys %neg) {
503 0 0       0 next unless defined $$fix{$param};
504 0         0 $$fix{$param} = -abs($$fix{$param});
505             }
506 93 50 66     522 if ($date and defined $secs and defined $$fix{lat} and defined $$fix{lon}) {
      66        
      33        
507 92         174 $time = $date + $secs;
508 92 50       208 $$has{alt} = 1 if defined $$fix{alt};
509 92 50       171 $$has{track} = 1 if defined $$fix{track};
510 92 50       179 $$has{orient} = 1 if defined $$fix{pitch};
511 92         1672 goto DoneFix;
512             }
513 1         3 next;
514             } elsif ($format eq 'JSON') {
515             # Google Takeout JSON format
516 0 0       0 if (/"(latitudeE7|longitudeE7|latE7|lngE7|timestamp)":\s*"?(.*?)"?,?\s*[\x0d\x0a]/) {
517 0 0 0     0 if ($1 eq 'timestamp') {
    0          
518 0         0 $time = GetTime($2);
519 0 0 0     0 goto DoneFix if $time and $$fix{lat} and $$fix{lon};
      0        
520             } elsif ($1 eq 'latitudeE7' or $1 eq 'latE7') {
521 0         0 $$fix{lat} = $2 * 1e-7;
522             } else {
523 0         0 $$fix{lon} = $2 * 1e-7;
524             }
525             }
526 0         0 next;
527             }
528 41         68 my (%fix, $secs, $date, $nmea);
529 41 100       75 if ($format eq 'NMEA') {
530             # ignore unrecognized NMEA sentences
531             # (first 2 characters: GP=GPS, GL=GLONASS, GA=Gallileo, GN=combined, BD=Beidou)
532 19 100       116 next unless /^(.*)\$([A-Z]{2}(RMC|GGA|GLL|GSA|ZDA)|PMGNTRK|PTNTHPR),/;
533 18   66     73 $nmea = $3 || $2;
534 18 50       46 $_ = substr($_, length($1)) if length($1);
535             }
536             #
537             # IGC (flarm) (ref 4)
538             #
539 40 100       130 if ($format eq 'IGC') {
    100          
    50          
    50          
    50          
    50          
    100          
    50          
540             # B0939564531208N00557021EA007670089100207
541             # BHHMMSSDDMMmmmNDDDMMmmmEAaaaaaAAAAAxxyy
542             # HH MM SS DD MM mmm DDD MM mmm aaaaa AAAAA
543             # 1 2 3 4 5 6 7 8 9 10 11 12 13 14
544 22 100       79 /^B(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})([NS])(\d{3})(\d{2})(\d{3})([EW])([AV])(\d{5})(\d{5})/ or next;
545 10 50       64 $fix{lat} = ($4 + ($5 + $6/1000)/60) * ($7 eq 'N' ? 1 : -1);
546 10 50       38 $fix{lon} = ($8 + ($9 +$10/1000)/60) * ($11 eq 'E' ? 1 : -1);
547 10 50       32 $fix{alt} = $12 eq 'A' ? $14 : undef;
548 10         25 $secs = (($1 * 60) + $2) * 60 + $3;
549             # wrap to next day if necessary
550 10 50       23 if ($dateFlarm) {
551 10 50       22 $dateFlarm += $secPerDay if $secs < $lastSecs - JITTER();
552 10         18 $date = $dateFlarm;
553             }
554 10         14 $nmea = 'B';
555             #
556             # NMEA RMC sentence (contains date)
557             #
558             } elsif ($nmea eq 'RMC') {
559             # $GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,*25
560             # $GPRMC,093657.007,,3652.835020,N,01053.104094,E,1.642,,290913,,,A*0F
561             # $GPRMC,hhmmss.sss,A/V,ddmm.mmmm,N/S,dddmm.mmmm,E/W,spd(knots),dir(deg),DDMMYY,,*cs
562 3 50       26 /^\$[A-Z]{2}RMC,(\d{2})(\d{2})(\d+(\.\d*)?),A?,(\d*?)(\d{1,2}\.\d+),([NS]),(\d*?)(\d{1,2}\.\d+),([EW]),(\d*\.?\d*),(\d*\.?\d*),(\d{2})(\d{2})(\d+)/ or next;
563 3 50 33     28 next if $13 > 31 or $14 > 12 or $15 > 99; # validate day/month/year
      33        
564 3 50 50     25 $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
565 3 50 50     52 $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
566 3 50       15 $fix{speed} = $11 if length $11;
567 3 50       15 $fix{track} = $12 if length $12;
568 3 50       10 my $year = $15 + ($15 >= 70 ? 1900 : 2000);
569 3         13 $secs = (($1 * 60) + $2) * 60 + $3;
570 3         15 $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
571             #
572             # NMEA GGA sentence (no date)
573             #
574             } elsif ($nmea eq 'GGA') {
575             # $GPGGA,092204.999,4250.5589,S,14718.5084,E,1,04,24.4,19.7,M,,,,0000*1F
576             # $GPGGA,093657.000,3652.835020,N,01053.104094,E,,8,,166.924,M,40.9,M,,*77
577             # $GPGGA,hhmmss.sss,ddmm.mmmm,N/S,dddmm.mmmm,E/W,0=invalid,sats,hdop,alt,M,...
578 0 0       0 /^\$[A-Z]{2}GGA,(\d{2})(\d{2})(\d+(\.\d*)?),(\d*?)(\d{1,2}\.\d+),([NS]),(\d*?)(\d{1,2}\.\d+),([EW]),[1-6]?,(\d+)?,(\.\d+|\d+\.?\d*)?,(-?\d+\.?\d*)?,M?/ or next;
579 0 0 0     0 $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
580 0 0 0     0 $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
581 0         0 @fix{qw(nsats hdop alt)} = ($11,$12,$13);
582 0         0 $secs = (($1 * 60) + $2) * 60 + $3;
583 0         0 $canCut = 1;
584             #
585             # NMEA GLL sentence (no date)
586             #
587             } elsif ($nmea eq 'GLL') {
588             # $GPGLL,4250.5589,S,14718.5084,E,092204.999,A*2D
589             # $GPGLL,ddmm.mmmm,N/S,dddmm.mmmm,E/W,hhmmss.sss,A/V*cs
590 0 0       0 /^\$[A-Z]{2}GLL,(\d*?)(\d{1,2}\.\d+),([NS]),(\d*?)(\d{1,2}\.\d+),([EW]),(\d{2})(\d{2})(\d+(\.\d*)?),A/ or next;
591 0 0 0     0 $fix{lat} = (($1 || 0) + $2/60) * ($3 eq 'N' ? 1 : -1);
592 0 0 0     0 $fix{lon} = (($4 || 0) + $5/60) * ($6 eq 'E' ? 1 : -1);
593 0         0 $secs = (($7 * 60) + $8) * 60 + $9;
594             #
595             # NMEA GSA sentence (satellite status, no date)
596             #
597             } elsif ($nmea eq 'GSA') {
598             # $GPGSA,A,3,04,05,,,,,,,,,,,pdop,hdop,vdop*HH
599 0 0       0 /^\$[A-Z]{2}GSA,[AM],([23]),((?:\d*,){11}(?:\d*)),(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?\*/ or next;
600 0         0 @fix{qw(fixtype sats pdop hdop vdop)} = ($1.'d',$2,$3,$4,$5);
601             # count the number of acquired satellites
602 0         0 my @a = ($fix{sats} =~ /\d+/g);
603 0         0 $fix{nsats} = scalar @a;
604 0         0 $canCut = 1;
605             #
606             # NMEA ZDA sentence (date/time, contains date)
607             #
608             } elsif ($nmea eq 'ZDA') {
609             # $GPZDA,093655.000,29,09,2013,,*58
610             # $GPZDA,hhmmss.ss,DD,MM,YYYY,tzh,tzm (hhmmss in UTC)
611 0 0       0 /^\$[A-Z]{2}ZDA,(\d{2})(\d{2})(\d{2}(\.\d*)?),(\d+),(\d+),(\d+)/ or next;
612 0         0 $secs = (($1 * 60) + $2) * 60 + $3;
613 0         0 $date = Time::Local::timegm(0,0,0,$5,$6-1,$7);
614             #
615             # Magellan eXplorist PMGNTRK (Proprietary MaGellaN TRacK) sentence (optional date)
616             #
617             } elsif ($nmea eq 'PMGNTRK') {
618             # $PMGNTRK,4415.026,N,07631.091,W,00092,M,185031.06,A,,020409*65
619             # $PMGNTRK,ddmm.mmm,N/S,dddmm.mmm,E/W,alt,F/M,hhmmss.ss,A/V,trkname,DDMMYY*cs
620 12 50       82 /^\$PMGNTRK,(\d+)(\d{2}\.\d+),([NS]),(\d+)(\d{2}\.\d+),([EW]),(-?\d+\.?\d*),([MF]),(\d{2})(\d{2})(\d+(\.\d*)?),A,(?:[^,]*,(\d{2})(\d{2})(\d+))?/ or next;
621 12 50       65 $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
622 12 50       52 $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
623 12 50       41 $fix{alt} = $8 eq 'M' ? $7 : $7 * 12 * 0.0254;
624 12         40 $secs = (($9 * 60) + $10) * 60 + $11;
625 12 50       28 if (defined $15) {
626 12 50 33     68 next if $13 > 31 or $14 > 12 or $15 > 99; # validate day/month/year
      33        
627             # optional date is available in PMGNTRK sentence
628 12 50       33 my $year = $15 + ($15 >= 70 ? 1900 : 2000);
629 12         43 $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
630             }
631             #
632             # Honeywell HMR3000 PTNTHPR (Heading Pitch Roll) sentence (no date)
633             # (ref http://www.gpsarea.com/uploadfile/download/introduce/hmr3000_manual.pdf)
634             #
635             } elsif ($nmea eq 'PTNTHPR') {
636             # $PTNTHPR,85.9,N,-0.9,N,0.8,N*HH
637             # $PTNTHPR,heading,heading status,pitch,pitch status,roll,roll status,*cs
638             # status: L=low alarm, M=low warning, N=normal, O=high warning
639             # P=high alarm, C=tuning analog circuit
640             # (ignore this information on any alarm status)
641 3 50       17 /^\$PTNTHPR,(-?[\d.]+),[MNO],(-?[\d.]+),[MNO],(-?[\d.]+),[MNO]/ or next;
642 3         18 @fix{qw(dir pitch roll)} = ($1,$2,$3);
643              
644             } else {
645 0         0 next; # this shouldn't happen
646             }
647             # remember the NMEA formats we successfully read
648 28         586 $nmea{$nmea} = 1;
649             # use last date if necessary (and appropriate)
650 28 50 66     106 if (defined $secs and not defined $date and defined $lastDate) {
      33        
651             # wrap to next day if necessary
652 0 0       0 if ($secs < $lastSecs - JITTER()) {
653 0         0 $lastSecs -= $secPerDay;
654 0         0 $lastDate += $secPerDay;
655             }
656             # use earlier date only if we are within 10 seconds
657 0 0       0 if ($secs - $lastSecs < 10) {
658             # last date is close, use it for this fix
659 0         0 $date = $lastDate;
660             } else {
661             # last date is old, discard it
662 0         0 undef $lastDate;
663 0         0 undef $lastSecs;
664             }
665             }
666             # save our last date/time
667 28 100       62 if (defined $date) {
668 25         37 $lastDate = $date;
669 25         38 $lastSecs = $secs;
670             }
671             #
672             # Add NMEA/IGC fix to our lookup
673             # (this is much more complicated than it needs to be because
674             # the stupid NMEA format provides no end-of-fix indication)
675             #
676             # assumptions for each NMEA sentence:
677             # - we only parse a time if we get a lat/lon
678             # - we always get a time if we have a date
679 28 100 0     77 if ($nmea eq $nmeaStart or (defined $secs and (not defined $fixSecs or
      33        
      66        
680             # don't combine sentences that are outside 10 seconds apart
681             ($secs >= $fixSecs and $secs - $fixSecs >= 10) or
682             ($secs < $fixSecs and $secs + $secPerDay - $fixSecs >= 10))))
683             {
684             # start a new fix
685 25         49 $fix = \%fix;
686 25         37 $fixSecs = $secs;
687 25         35 undef $noDateChanged;
688             # does this fix have a date/time or time stamp?
689 25 50       46 if (defined $date) {
    0          
690 25         47 $fix{isDate} = $isDate = 1;
691 25         44 $time = $date + $secs;
692             } elsif (defined $secs) {
693 0         0 $time = $secs;
694 0         0 $noDate = $noDateChanged = 1;
695             } else {
696 0         0 next; # wait until we have a time before adding to lookup
697             }
698             } else {
699             # add new data to existing fix (but don't overwrite earlier values to
700             # keep the coordinates in sync with the fix time)
701 3         11 foreach (keys %fix) {
702 9 50       32 $$fix{$_} = $fix{$_} unless defined $$fix{$_};
703             }
704 3 50 33     18 if (defined $date) {
    50          
705 0 0       0 next if $$fix{isDate};
706             # move this fix to the proper date
707 0 0       0 if (defined $fixSecs) {
708 0         0 delete $$points{$fixSecs};
709 0 0 0     0 pop @fixTimes if @fixTimes and $fixTimes[-1] == $fixSecs;
710 0         0 --$numPoints;
711             # if we wrapped to the next day since the start of this fix,
712             # we must shift the date back to the day of $fixSecs
713 0 0       0 $date -= $secPerDay if $secs < $fixSecs;
714             } else {
715 0         0 $fixSecs = $secs;
716             }
717 0         0 $time = $date + $fixSecs;
718 0         0 $$fix{isDate} = $isDate = 1;
719             # revert noDate flag if it was set for this fix
720 0 0       0 $noDate = 0 if $noDateChanged;
721             } elsif (defined $secs and not defined $fixSecs) {
722 0         0 $time = $fixSecs = $secs;
723 0         0 $noDate = $noDateChanged = 1;
724             } else {
725 3         7 next; # wait until we have a time
726             }
727             }
728             # add fix to our lookup
729 25         117 $$points{$time} = $fix;
730 25         54 push @fixTimes, $time; # save time of all fixes in order
731 25         41 ++$numPoints;
732             }
733 8         48 $raf->Close();
734              
735             # set date flags
736 8 50 33     59 if ($noDate and not $$geotag{NoDate}) {
737 0 0       0 if ($isDate) {
738 0         0 $et->Warn('Fixes are date-less -- will use time-only interpolation');
739             } else {
740 0         0 $et->Warn('Some fixes are date-less -- may use time-only interpolation');
741             }
742 0         0 $$geotag{NoDate} = 1;
743             }
744 8 50       47 $$geotag{IsDate} = 1 if $isDate;
745              
746             # cut bad fixes if necessary
747 8 50 33     39 if ($isCut and $canCut) {
748 0         0 $cutPDOP = $cutHDOP = $cutSats = 0;
749 0         0 my @goodTimes;
750 0         0 foreach (@fixTimes) {
751 0 0       0 $fix = $$points{$_} or next;
752 0 0 0     0 if ($maxPDOP and $$fix{pdop} and $$fix{pdop} > $maxPDOP) {
    0 0        
    0 0        
      0        
      0        
      0        
      0        
753 0         0 delete $$points{$_};
754 0         0 ++$cutPDOP;
755             } elsif ($maxHDOP and $$fix{hdop} and $$fix{hdop} > $maxHDOP) {
756 0         0 delete $$points{$_};
757 0         0 ++$cutHDOP;
758             } elsif ($minSats and defined $$fix{nsats} and $$fix{nsats} ne '' and
759             $$fix{nsats} < $minSats)
760             {
761 0         0 delete $$points{$_};
762 0         0 ++$cutSats;
763             } else {
764 0         0 push @goodTimes, $_;
765             }
766             }
767 0         0 @fixTimes = @goodTimes; # update fix times
768 0         0 $numPoints -= $cutPDOP;
769 0         0 $numPoints -= $cutHDOP;
770 0         0 $numPoints -= $cutSats;
771             }
772             # sort fixes if necessary
773 8 50       24 @fixTimes = sort { $a <=> $b } @fixTimes if $sortFixes;
  0         0  
774             # mark first fix of the track
775 8         25 while (@fixTimes) {
776 8 50       54 $fix = $$points{$fixTimes[0]} or shift(@fixTimes), next;
777 8         26 $$fix{first} = 1;
778 8         16 last;
779             }
780 8         54 my $verbose = $et->Options('Verbose');
781 8 100       55 if ($verbose) {
782 1         5 my $out = $et->Options('TextOut');
783 1 50       5 $format or $format = 'unknown';
784 1         8 print $out "Loaded $numPoints points from $format-format GPS track log $from\n";
785 1 50       4 print $out "Ignored $cutPDOP points due to GeoMaxPDOP cut\n" if $cutPDOP;
786 1 50       3 print $out "Ignored $cutHDOP points due to GeoMaxHDOP cut\n" if $cutHDOP;
787 1 50       4 print $out "Ignored $cutSats points due to GeoMinSats cut\n" if $cutSats;
788 1 50 33     8 if ($numPoints and $verbose > 1) {
789 1         5 my @lbl = ('start:', 'end: ');
790             # (fixes may be in reverse order in GPX files)
791 1 50       4 @lbl = reverse @lbl if $fixTimes[0] > $fixTimes[-1];
792 1         6 print $out " GPS track $lbl[0] " . PrintFixTime($fixTimes[0]) . "\n";
793 1 50       7 if ($verbose > 3) {
794 0         0 print $out PrintFix($points, $_) foreach @fixTimes;
795             }
796 1         6 print $out " GPS track $lbl[1] " . PrintFixTime($fixTimes[-1]) . "\n";
797             }
798             }
799 8 50       29 if ($numPoints) {
800             # reset timestamp list to force it to be regenerated
801 8         21 delete $$geotag{Times};
802             # set flags for available information
803 8 100 66     80 $$has{alt} = 1 if $nmea{GGA} or $nmea{PMGNTRK} or $nmea{B}; # alt
      100        
804 8 100       27 $$has{track} = 1 if $nmea{RMC}; # track, speed
805 8 100       52 $$has{orient} = 1 if $nmea{PTNTHPR}; # dir, pitch, roll
806 8         310 return $geotag; # success!
807             }
808 0         0 return "No track points found in GPS $from";
809             }
810              
811              
812             #------------------------------------------------------------------------------
813             # Get floating point UTC time
814             # Inputs: 0) XML time string
815             # Returns: floating point time or undef on error
816             sub GetTime($)
817             {
818 14     14 0 29 my $timeStr = shift;
819 14 50       83 $timeStr =~ /^(\d{4})-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(.*)/ or return undef;
820 14         77 my $time = Time::Local::timegm($6,$5,$4,$3,$2-1,$1);
821 14 100       542 $time += $7 if $7; # add fractional seconds
822 14         25 my $tz = $8;
823             # adjust for time zone (otherwise assume UTC)
824             # - allow timezone of +-HH:MM, +-H:MM, +-HHMM or +-HH since
825             # the spec is unclear about timezone format
826 14 100 66     63 if ($tz =~ /^([-+])(\d+):(\d{2})\b/ or $tz =~ /^([-+])(\d{2})(\d{2})?\b/) {
827 3   50     18 $tz = ($2 * 60 + ($3 || 0)) * 60;
828 3 50       8 $tz *= -1 if $1 eq '+'; # opposite sign to change back to UTC
829 3         7 $time += $tz;
830             }
831 14         43 return $time;
832             }
833              
834             #------------------------------------------------------------------------------
835             # Apply Geosync time correction
836             # Inputs: 0) ExifTool ref, 1) Unix UTC time value
837             # Returns: sync time difference (and updates input time), or undef if no sync
838             sub ApplySyncCorr($$)
839             {
840 10     10 0 38 my ($et, $time) = @_;
841 10         53 my $sync = $et->GetNewValue('Geosync');
842 10 100       61 if (ref $sync eq 'HASH') {
843 3         14 my $syncTimes = $$sync{Times};
844 3 100       10 if ($syncTimes) {
845             # find the nearest 2 sync points
846 2         8 my ($i0, $i1) = (0, scalar(@$syncTimes) - 1);
847 2         11 while ($i1 > $i0 + 1) {
848 0         0 my $pt = int(($i0 + $i1) / 2);
849 0 0       0 ($time < $$syncTimes[$pt] ? $i1 : $i0) = $pt;
850             }
851 2         8 my ($t0, $t1) = ($$syncTimes[$i0], $$syncTimes[$i1]);
852             # interpolate/extrapolate to account for linear camera clock drift
853 2         8 my $syncPoints = $$sync{Points};
854 2 50       11 my $f = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
855 2         10 $sync = $$syncPoints{$t1} * $f + $$syncPoints{$t0} * (1 - $f);
856             } else {
857 1         4 $sync = $$sync{Offset}; # use fixed time offset
858             }
859 3         8 $_[1] += $sync;
860             } else {
861 7         14 undef $sync;
862             }
863 10         27 return $sync;
864             }
865              
866             #------------------------------------------------------------------------------
867             # Scan outwards for a fix containing the requested parameter
868             # Inputs: 0) name of fix parameter, 1) reference to list of fix times,
869             # 2) reference to fix points hash, 3) index of starting time,
870             # 4) direction to scan (-1 or +1), 5) maximum time difference
871             # Returns: 0) time for fix containing requested information (or undef)
872             # 1) the corresponding fix, 2) the value of the requested fix parameter
873             sub ScanOutwards($$$$$$)
874             {
875 3     3 0 8 my ($key, $times, $points, $i, $dir, $maxSecs) = @_;
876 3         5 my $t0 = $$times[$i];
877 3         7 for (;;) {
878 4         6 $i += $dir;
879 4 50 33     18 last if $i < 0 or $i >= scalar @$times;
880 4         8 my $t = $$times[$i];
881 4 100       17 last if abs($t - $t0) > $maxSecs; # don't look too far
882 1         3 my $p = $$points{$t};
883 1         2 my $v = $$p{$key};
884 1 50       6 return($t,$p,$v) if defined $v;
885             }
886 3         7 return();
887             }
888              
889             #------------------------------------------------------------------------------
890             # Find nearest fix containing the specified parameter
891             # Inputs: 0) ExifTool ref, 1) name of fix parameter, 2) reference to list of fix times,
892             # 3) reference to fix points hash, 4) index of starting time,
893             # 5) direction to scan (-1, +1 or undef), 6) maximum time difference
894             # Returns: reference to fix hash or undef
895             sub FindFix($$$$$$$)
896             {
897 0     0 0 0 my ($et, $key, $times, $points, $i, $dir, $maxSecs) = @_;
898 0         0 my ($t,$p);
899 0 0       0 if ($dir) {
900 0         0 ($t,$p) = ScanOutwards($key, $times, $points, $i, $dir, $maxSecs);
901             } else {
902 0         0 my ($t1, $p1) = ScanOutwards($key, $times, $points, $i, -1, $maxSecs);
903 0         0 my ($t2, $p2) = ScanOutwards($key, $times, $points, $i, 1, $maxSecs);
904 0 0       0 if (defined $t1) {
    0          
905 0 0       0 if (defined $t2) {
906             # both surrounding points are valid, so take the closest one
907 0 0       0 ($t, $p) = ($t - $t1 < $t2 - $t) ? ($t1, $p1) : ($t2, $p2);
908             } else {
909 0         0 ($t, $p) = ($t1, $p1);
910             }
911             } elsif (defined $t2) {
912 0         0 ($t, $p) = ($t2, $p2);
913             }
914             }
915 0 0 0     0 if (defined $p and $$et{OPTIONS}{Verbose} > 2) {
916 0         0 $et->VPrint(2, " Taking $key from fix:\n", PrintFix($points, $t))
917             }
918 0         0 return $p;
919             }
920              
921             #------------------------------------------------------------------------------
922             # Set new geotagging values according to date/time
923             # Inputs: 0) ExifTool object ref, 1) date/time value (or undef to delete tags)
924             # 2) optional write group
925             # Returns: error string, or '' on success
926             # Notes: Uses track data stored in ExifTool NEW_VALUE for Geotag tag
927             sub SetGeoValues($$;$)
928             {
929 13     13 0 31 local $_;
930 13         47 my ($et, $val, $writeGroup) = @_;
931 13         75 my $geotag = $et->GetNewValue('Geotag');
932 13         53 my $verbose = $et->Options('Verbose');
933 13         40 my ($fix, $time, $fsec, $noDate, $secondTry, $iExt, $iDir);
934              
935             # remove date if none of our fixes had date information
936 13 50 66     99 $val =~ s/^\S+\s+// if $val and $geotag and not $$geotag{IsDate};
      66        
937              
938             # maximum time (sec) from nearest GPS fix when position is still considered valid
939 13         38 my $geoMaxIntSecs = $et->Options('GeoMaxIntSecs');
940 13         56 my $geoMaxExtSecs = $et->Options('GeoMaxExtSecs');
941              
942             # use 30 minutes for a default
943 13 50       59 defined $geoMaxIntSecs or $geoMaxIntSecs = 1800;
944 13 50       59 defined $geoMaxExtSecs or $geoMaxExtSecs = 1800;
945              
946 13         28 my $times = $$geotag{Times};
947 13         32 my $points = $$geotag{Points};
948 13         21 my $has = $$geotag{Has};
949 13         32 my $err = '';
950             # loop to try date/time value first, then time-only value
951 13         53 while (defined $val) {
952 10 50       33 unless (defined $geotag) {
953 0         0 $err = 'No GPS track loaded';
954 0         0 last;
955             }
956 10 100       26 unless ($times) {
957             # generate sorted timestamp list for binary search
958 9         82 my @times = sort { $a <=> $b } keys %$points;
  600         840  
959 9         47 $times = $$geotag{Times} = \@times;
960             }
961 10 50 66     54 unless ($times and @$times or $$geotag{DateTimeOnly}) {
      66        
962 0         0 $err = 'GPS track is empty';
963 0         0 last;
964             }
965 10 50       20 unless (eval { require Time::Local }) {
  10         83  
966 0         0 $err = 'Geotag feature requires Time::Local installed';
967 0         0 last;
968             }
969             # convert date/time to UTC
970 10         33 my ($year,$mon,$day,$hr,$min,$sec,$fs,$tz,$t0,$t1,$t2);
971 10 50       91 if ($val =~ /^(\d{4}):(\d+):(\d+)\s+(\d+):(\d+):(\d+)(\.\d*)?(Z|([-+])(\d+):(\d+))?/) {
    0          
972             # valid date/time value
973 10         107 ($year,$mon,$day,$hr,$min,$sec,$fs,$tz,$t0,$t1,$t2) = ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11);
974             } elsif ($val =~ /^(\d{2}):(\d+):(\d+)(\.\d*)?(Z|([-+])(\d+):(\d+))?/) {
975             # valid time-only value
976 0         0 ($hr,$min,$sec,$fs,$tz,$t0,$t1,$t2) = ($1,$2,$3,$4,$5,$6,$7,$8);
977             # use Jan. 2 to avoid going negative after tz adjustment
978 0         0 ($year,$mon,$day) = (1970,1,2);
979 0         0 $noDate = 1;
980             } else {
981 0         0 $err = 'Invalid date/time (use YYYY:mm:dd HH:MM:SS[.ss][+/-HH:MM|Z])';
982 0         0 last;
983             }
984 10 100       24 if ($tz) {
985 9         52 $time = Time::Local::timegm($sec,$min,$hr,$day,$mon-1,$year);
986             # use timezone from date/time value
987 9 100       347 if ($tz ne 'Z') {
988 5         17 my $tzmin = $t1 * 60 + $t2;
989 5 100       22 $time -= ($t0 eq '-' ? -$tzmin : $tzmin) * 60;
990             }
991             } else {
992             # assume local timezone
993 1         6 $time = Image::ExifTool::TimeLocal($sec,$min,$hr,$day,$mon-1,$year);
994             }
995             # add fractional seconds
996 10 100 66     60 $time += $fs if $fs and $fs ne '.';
997              
998             # bring UTC time back to Jan. 1 if no date is given
999             # (don't use '%' operator here because it drops fractional seconds)
1000 10 50       39 $time -= int($time / $secPerDay) * $secPerDay if $noDate;
1001              
1002             # apply time synchronization if available
1003 10         42 my $sync = ApplySyncCorr($et, $time);
1004              
1005             # save fractional seconds string
1006 10 100       64 $fsec = ($time =~ /(\.\d+)$/) ? $1 : '';
1007              
1008 10 100 66     37 if ($et->Options('Verbose') > 1 and not $secondTry) {
1009 1         4 my $out = $et->Options('TextOut');
1010 1         3 my $str = '';
1011 1 50       12 $str .= sprintf(" (incl. Geosync offset of %+.3f sec)", $sync) if defined $sync;
1012 1 50       5 unless ($tz) {
1013 0         0 my $tzs = Image::ExifTool::TimeZoneString([$sec,$min,$hr,$day,$mon-1,$year-1900],$time);
1014 0         0 $str .= " (local timezone is $tzs)";
1015             }
1016 1         5 print $out ' Geotime value: ' . PrintFixTime($time) . "$str\n";
1017             }
1018 10 100 66     95 if (not $times or not @$times) {
    50          
    100          
1019 1         3 $fix = { }; # dummy fix to geotag date/time only
1020             # interpolate GPS track at $time
1021             } elsif ($time < $$times[0]) {
1022 0 0       0 if ($time < $$times[0] - $geoMaxExtSecs) {
1023 0 0       0 $err or $err = 'Time is too far before track';
1024 0 0       0 $et->VPrint(2, ' Track start: ', PrintFixTime($$times[0]), "\n") if $verbose > 2;
1025 0 0       0 $fix = { } if $$geotag{DateTimeOnly};
1026             } else {
1027 0         0 $fix = $$points{$$times[0]};
1028 0         0 $iExt = 0; $iDir = 1;
  0         0  
1029 0 0       0 $et->VPrint(2, " Taking pos from fix:\n",
1030             PrintFix($points, $$times[0])) if $verbose > 2;
1031             }
1032             } elsif ($time > $$times[-1]) {
1033 1 50       5 if ($time > $$times[-1] + $geoMaxExtSecs) {
1034 1 50       5 $err or $err = 'Time is too far beyond track';
1035 1 50       5 $et->VPrint(2, ' Track end: ', PrintFixTime($$times[-1]), "\n") if $verbose > 2;
1036 1 50       5 $fix = { } if $$geotag{DateTimeOnly};
1037             } else {
1038 0         0 $fix = $$points{$$times[-1]};
1039 0         0 $iExt = $#$times; $iDir = -1;
  0         0  
1040 0 0       0 $et->VPrint(2, " Taking pos from fix:\n",
1041             PrintFix($points, $$times[-1])) if $verbose > 2;
1042             }
1043             } else {
1044             # find nearest 2 points in time
1045 8         29 my ($i0, $i1) = (0, scalar(@$times) - 1);
1046 8         40 while ($i1 > $i0 + 1) {
1047 24         45 my $pt = int(($i0 + $i1) / 2);
1048 24 100       62 ($time < $$times[$pt] ? $i1 : $i0) = $pt;
1049             }
1050             # do linear interpolation for position
1051 8         23 my $t0 = $$times[$i0];
1052 8         19 my $t1 = $$times[$i1];
1053 8         32 my $p1 = $$points{$t1};
1054             # check to see if we are extrapolating before the first entry in a track
1055 8 50 33     44 my $maxSecs = ($$p1{first} and $geoMaxIntSecs) ? $geoMaxExtSecs : $geoMaxIntSecs;
1056             # don't interpolate if fixes are too far apart
1057             # (but always interpolate fixes inside the same TimeSpan)
1058 8 50 0     36 if ($t1 - $t0 > $maxSecs and (not $$p1{span} or not $$points{$t0}{span} or
      33        
1059             $$p1{span} != $$points{$t0}{span}))
1060             {
1061             # treat as an extrapolation -- use nearest fix if close enough
1062 0         0 my $tn;
1063 0 0       0 if ($time - $t0 < $t1 - $time) {
1064 0         0 $tn = $t0;
1065 0         0 $iExt = $i0;
1066             } else {
1067 0         0 $tn = $t1;
1068 0         0 $iExt = $i1;
1069             }
1070 0 0       0 if (abs($time - $tn) > $geoMaxExtSecs) {
1071 0 0       0 $err or $err = 'Time is too far from nearest GPS fix';
1072 0 0       0 $et->VPrint(2, ' Nearest fix: ', PrintFixTime($tn), "\n") if $verbose > 2;
1073 0 0       0 $fix = { } if $$geotag{DateTimeOnly};
1074             } else {
1075 0         0 $fix = $$points{$tn};
1076 0 0       0 $et->VPrint(2, " Taking pos from fix:\n",
1077             PrintFix($points, $tn)) if $verbose > 2;
1078             }
1079             } else {
1080 8 50       28 my $f0 = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
1081 8         15 my $p0 = $$points{$t0};
1082 8 50       25 $et->VPrint(2, " Interpolating between fixes (f=$f0):\n",
1083             PrintFix($points, $t0, $t1)) if $verbose > 2;
1084 8         16 $fix = { };
1085             # loop through available fix information categories
1086             # (pos, track, alt, orient)
1087 8         19 my ($category, $key);
1088 8         28 Category: foreach $category (qw{pos track alt orient atemp}) {
1089 40 100       121 next unless $$has{$category};
1090 19         38 my ($f, $p0b, $p1b, $f0b);
1091             # loop through specific fix information keys
1092             # (lat, lon, alt, track, speed, dir, pitch, roll)
1093 19         29 foreach $key (@{$fixInfoKeys{$category}}) {
  19         61  
1094 33         85 my $v0 = $$p0{$key};
1095 33         55 my $v1 = $$p1{$key};
1096 33 100 66     104 if (defined $v0 and defined $v1) {
    50          
1097 30         42 $f = $f0;
1098             } elsif (defined $f0b) {
1099 0         0 $v0 = $$p0b{$key};
1100 0         0 $v1 = $$p1b{$key};
1101 0 0 0     0 next unless defined $v0 and defined $v1;
1102 0         0 $f = $f0b;
1103             } else {
1104             # scan outwards looking for fixes with the required information
1105             # (NOTE: SHOULD EVENTUALLY DO THIS FOR EXTRAPOLATION TOO!)
1106 3         7 my ($t0b, $t1b);
1107 3 50       8 if (defined $v0) {
1108 0         0 $t0b = $t0; $p0b = $p0;
  0         0  
1109             } else {
1110 3         11 ($t0b,$p0b,$v0) = ScanOutwards($key,$times,$points,$i0,-1,$maxSecs);
1111 3 50       10 next Category unless defined $t0b;
1112             }
1113 0 0       0 if (defined $v1) {
1114 0         0 $t1b = $t1; $p1b = $p1;
  0         0  
1115             } else {
1116 0         0 ($t1b,$p1b,$v1) = ScanOutwards($key,$times,$points,$i1,1,$maxSecs);
1117 0 0       0 next Category unless defined $t1b;
1118             }
1119             # re-calculate the interpolation factor
1120 0 0       0 $f = $f0b = $t1b == $t0b ? 0 : ($time - $t0b) / ($t1b - $t0b);
1121 0 0       0 $et->VPrint(2, " Interpolating $category between fixes (f=$f):\n",
1122             PrintFix($points, $t0b, $t1b)) if $verbose > 2;
1123             }
1124             # must interpolate cyclical values differently
1125 30 50 66     160 if ($cyclical{$key} and abs($v1 - $v0) > 180) {
1126             # the acute angle spans the discontinuity, so add
1127             # 360 degrees to the smaller angle before interpolating
1128 0 0       0 $v0 < $v1 ? $v0 += 360 : $v1 += 360;
1129 0         0 $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
1130             # longitude and roll ranges are -180 to 180, others are 0 to 360
1131 0 0 0     0 my $max = ($key eq 'lon' or $key eq 'roll') ? 180 : 360;
1132 0 0       0 $$fix{$key} -= 360 if $$fix{$key} >= $max;
1133             } else {
1134             # simple linear interpolation
1135 30         121 $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
1136             }
1137             }
1138             }
1139             }
1140             }
1141 10 100 33     59 if ($fix) {
    50 33        
1142 9         29 $err = ''; # success!
1143             } elsif ($$geotag{NoDate} and not $noDate and $val =~ s/^\S+\s+//) {
1144             # try again with no date since some of our track points are date-less
1145 0         0 $secondTry = 1;
1146 0         0 next;
1147             }
1148 10         26 last;
1149             }
1150 13 100       43 if ($fix) {
1151 9         19 my ($gpsDate, $gpsAlt, $gpsAltRef);
1152 9         75 my @t = gmtime(int $time);
1153 9         66 my $gpsTime = sprintf('%.2d:%.2d:%.2d', $t[2], $t[1], $t[0]) . $fsec;
1154             # write GPSDateStamp if date included in track log, otherwise delete it
1155 9 50       60 $gpsDate = sprintf('%.2d:%.2d:%.2d', $t[5]+1900, $t[4]+1, $t[3]) unless $noDate;
1156             # write GPSAltitude tags if altitude included in track log, otherwise delete them
1157 9 100 33     46 if (defined $$fix{alt}) {
    50          
1158 5         21 $gpsAlt = abs $$fix{alt};
1159 5 50       20 $gpsAltRef = ($$fix{alt} < 0 ? 1 : 0);
1160             } elsif ($$has{alt} and defined $iExt) {
1161 0         0 my $tFix = FindFix($et,'alt',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
1162 0 0       0 if ($tFix) {
1163 0         0 $gpsAlt = abs $$tFix{alt};
1164 0 0       0 $gpsAltRef = ($$tFix{alt} < 0 ? 1 : 0);
1165             }
1166             }
1167             # set new GPS tag values (EXIF, or XMP if write group is 'xmp')
1168 9         22 my ($xmp, $exif, @r);
1169 9         32 my %opts = ( Type => 'ValueConv' ); # write ValueConv values
1170 9 100       27 if ($writeGroup) {
1171 1         4 $opts{Group} = $writeGroup;
1172 1         6 $xmp = ($writeGroup =~ /xmp/i);
1173 1         6 $exif = ($writeGroup =~ /^(exif|gps)$/i);
1174             }
1175             # (capture error messages by calling SetNewValue in list context)
1176 9         100 @r = $et->SetNewValue(GPSLatitude => $$fix{lat}, %opts);
1177 9         61 @r = $et->SetNewValue(GPSLongitude => $$fix{lon}, %opts);
1178 9         60 @r = $et->SetNewValue(GPSAltitude => $gpsAlt, %opts);
1179 9         65 @r = $et->SetNewValue(GPSAltitudeRef => $gpsAltRef, %opts);
1180 9 100       61 if ($$has{track}) {
1181 3         23 my $tFix = $fix;
1182 3 50 66     35 if (not defined $$fix{track} and defined $iExt) {
1183 0         0 my $p = FindFix($et,'track',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
1184 0 0       0 $tFix = $p if $p;
1185             }
1186 3         17 @r = $et->SetNewValue(GPSTrack => $$tFix{track}, %opts);
1187 3 100       33 @r = $et->SetNewValue(GPSTrackRef => (defined $$tFix{track} ? 'T' : undef), %opts);
1188 3         11 my ($spd, $ref);
1189 3 100       16 if (defined($spd = $$tFix{speed})) {
1190 1         3 $ref = $$et{OPTIONS}{GeoSpeedRef};
1191 1 50 33     6 if ($ref and defined $speedConv{$ref}) {
1192 0 0       0 $ref = $speedConv{$ref} if $speedConv{$speedConv{$ref}};
1193 0         0 $spd *= $speedConv{$ref};
1194             } else {
1195 1         3 $ref = 'N'; # knots by default
1196             }
1197             }
1198 3         17 @r = $et->SetNewValue(GPSSpeed => $spd, %opts);
1199 3         18 @r = $et->SetNewValue(GPSSpeedRef => $ref, %opts);
1200             }
1201 9 100       33 if ($$has{orient}) {
1202 3         8 my $tFix = $fix;
1203 3 50 66     19 if (not defined $$fix{dir} and defined $iExt) {
1204 0         0 my $p = FindFix($et,'dir',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
1205 0 0       0 $tFix = $p if $p;
1206             }
1207 3         17 @r = $et->SetNewValue(GPSImgDirection => $$tFix{dir}, %opts);
1208 3 100       30 @r = $et->SetNewValue(GPSImgDirectionRef => (defined $$tFix{dir} ? 'T' : undef), %opts);
1209 3         27 @r = $et->SetNewValue(CameraElevationAngle => $$tFix{pitch}, %opts);
1210             # Note: GPSPitch and GPSRoll are non-standard, and must be user-defined
1211 3         26 @r = $et->SetNewValue(GPSPitch => $$tFix{pitch}, %opts);
1212 3         27 @r = $et->SetNewValue(GPSRoll => $$tFix{roll}, %opts);
1213             }
1214 9 50       116 if ($$has{atemp}) {
1215 0         0 my $tFix = $fix;
1216 0 0 0     0 if (not defined $$fix{atemp} and defined $iExt) {
1217             # (not all fixes have atemp, so try interpolating specifically for this)
1218 0         0 my $p = FindFix($et,'atemp',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
1219 0 0       0 $tFix = $p if $p;
1220             }
1221 0         0 @r = $et->SetNewValue(AmbientTemperature => $$tFix{atemp}, %opts);
1222             }
1223 9 100       31 unless ($xmp) {
1224 8         17 my ($latRef, $lonRef);
1225 8 100       85 $latRef = ($$fix{lat} > 0 ? 'N' : 'S') if defined $$fix{lat};
    100          
1226 8 100       48 $lonRef = ($$fix{lon} > 0 ? 'E' : 'W') if defined $$fix{lon};
    100          
1227 8         39 @r = $et->SetNewValue(GPSLatitudeRef => $latRef, %opts);
1228 8         45 @r = $et->SetNewValue(GPSLongitudeRef => $lonRef, %opts);
1229 8         41 @r = $et->SetNewValue(GPSDateStamp => $gpsDate, %opts);
1230 8         51 @r = $et->SetNewValue(GPSTimeStamp => $gpsTime, %opts);
1231             # set options to edit XMP:GPSDateTime only if it already exists
1232 8         40 $opts{EditOnly} = 1;
1233 8         33 $opts{Group} = 'XMP';
1234             }
1235 9 50       34 unless ($exif) {
1236 9         55 @r = $et->SetNewValue(GPSDateTime => "$gpsDate $gpsTime", %opts);
1237             }
1238             } else {
1239 4         16 my %opts = ( IgnorePermanent => 1 );
1240 4 100       15 $opts{Replace} = 2 if defined $val; # remove existing new values
1241 4 100       15 $opts{Group} = $writeGroup if $writeGroup;
1242              
1243             # reset any GPS values we might have already set
1244 4         15 foreach (qw(GPSLatitude GPSLatitudeRef GPSLongitude GPSLongitudeRef
1245             GPSAltitude GPSAltitudeRef GPSDateStamp GPSTimeStamp GPSDateTime
1246             GPSTrack GPSTrackRef GPSSpeed GPSSpeedRef GPSImgDirection
1247             GPSImgDirectionRef GPSPitch GPSRoll CameraElevationAngle
1248             AmbientTemperature))
1249             {
1250 76         306 my @r = $et->SetNewValue($_, undef, %opts);
1251             }
1252             }
1253 13         188 return $err;
1254             }
1255              
1256             #------------------------------------------------------------------------------
1257             # Convert Geotagging time synchronization value
1258             # Inputs: 0) exiftool object ref,
1259             # 1) time difference string ("[+-]DD MM:HH:SS.ss"), geosync'd file name,
1260             # "GPSTIME@IMAGETIME", or "GPSTIME@FILENAME"
1261             # Returns: geosync hash:
1262             # Offset = Offset in seconds for latest synchronization (GPS - image time)
1263             # Points = hash of all sync offsets keyed by image times in seconds
1264             # Times = sorted list of image synchronization times (keys in Points hash)
1265             # Notes: calling this routine with more than one geosync'd file causes time drift
1266             # correction to be implemented
1267             sub ConvertGeosync($$)
1268             {
1269 5     5 0 18 my ($et, $val) = @_;
1270 5   100     23 my $sync = $et->GetNewValue('Geosync') || { };
1271 5         14 my ($syncFile, $gpsTime, $imgTime);
1272              
1273 5 100 33     215 if ($val =~ /(.*?)\@(.*)/) {
    50          
1274 4         15 $gpsTime = $1;
1275 4 50       98 (-f $2 ? $syncFile : $imgTime) = $2;
1276             # (take care because "-f '1:30'" crashes ActivePerl 5.10)
1277             } elsif ($val !~ /^\d/ or $val !~ /:/) {
1278 0 0       0 $syncFile = $val if -f $val;
1279             }
1280 5 100 66     29 if ($gpsTime or defined $syncFile) {
1281             # (this is a time synchronization vector)
1282 4 50       13 if (defined $syncFile) {
1283             # check the following tags in order to obtain the image timestamp
1284 0         0 my @timeTags = qw(SubSecDateTimeOriginal SubSecCreateDate SubSecModifyDate
1285             DateTimeOriginal CreateDate ModifyDate FileModifyDate);
1286 0         0 my $info = ImageInfo($syncFile, { PrintConv => 0 }, @timeTags,
1287             'GPSDateTime', 'GPSTimeStamp');
1288 0 0       0 $$info{Error} and warn("$$info{Err}\n"), return undef;
1289 0 0       0 unless ($gpsTime) {
1290 0   0     0 $gpsTime = $$info{GPSDateTime} || $$info{GPSTimeStamp};
1291 0 0 0     0 $gpsTime .= 'Z' if $gpsTime and not $$info{GPSDateTime};
1292             }
1293 0 0       0 $gpsTime or warn("No GPSTimeStamp in '$syncFile\n"), return undef;
1294 0         0 my $tag;
1295 0         0 foreach $tag (@timeTags) {
1296 0 0       0 if ($$info{$tag}) {
1297 0         0 $imgTime = $$info{$tag};
1298 0         0 $et->VPrint(2, "Geosyncing with $tag from '${syncFile}'\n");
1299 0         0 last;
1300             }
1301             }
1302 0 0       0 $imgTime or warn("No image timestamp in '${syncFile}'\n"), return undef;
1303             }
1304             # add date to date-less timestamps
1305 4         8 my ($imgDateTime, $gpsDateTime, $noDate);
1306 4 50       27 if ($imgTime =~ /^(\d+:\d+:\d+)\s+\d+/) {
    0          
1307 4         9 $imgDateTime = $imgTime;
1308 4         10 my $date = $1;
1309 4 50       17 if ($gpsTime =~ /^\d+:\d+:\d+\s+\d+/) {
1310 4         9 $gpsDateTime = $gpsTime;
1311             } else {
1312 0         0 $gpsDateTime = "$date $gpsTime";
1313             }
1314             } elsif ($gpsTime =~ /^(\d+:\d+:\d+)\s+\d+/) {
1315 0         0 $imgDateTime = "$1 $imgTime";
1316 0         0 $gpsDateTime = $gpsTime;
1317             } else {
1318             # use a today's date (so hopefully the DST setting will be intuitive)
1319 0         0 my @tm = localtime;
1320 0         0 my $date = sprintf('%.4d:%.2d:%.2d', $tm[5]+1900, $tm[4]+1, $tm[3]);
1321 0         0 $gpsDateTime = "$date $gpsTime";
1322 0         0 $imgDateTime = "$date $imgTime";
1323 0         0 $noDate = 1;
1324             }
1325             # calculate Unix seconds since the epoch
1326 4         21 my $imgSecs = Image::ExifTool::GetUnixTime($imgDateTime, 1);
1327 4 50       13 defined $imgSecs or warn("Invalid image time '${imgTime}'\n"), return undef;
1328 4         12 my $gpsSecs = Image::ExifTool::GetUnixTime($gpsDateTime, 1);
1329 4 50       13 defined $gpsSecs or warn("Invalid GPS time '${gpsTime}'\n"), return undef;
1330             # add fractional seconds
1331 4 50       16 $gpsSecs += $1 if $gpsTime =~ /(\.\d+)/;
1332 4 50       13 $imgSecs += $1 if $imgTime =~ /(\.\d+)/;
1333             # shift dates within 12 hours of each other if either timestamp was date-less
1334 4 50 33     19 if ($gpsDateTime ne $gpsTime or $imgDateTime ne $imgTime) {
1335 0         0 my $diff = ($imgSecs - $gpsSecs) % (24 * 3600);
1336 0 0       0 $diff -= 24 * 3600 if $diff > 12 * 3600;
1337 0 0       0 $diff += 24 * 3600 if $diff < -12 * 3600;
1338 0 0       0 if ($gpsDateTime ne $gpsTime) {
1339 0         0 $gpsSecs = $imgSecs - $diff;
1340             } else {
1341 0         0 $imgSecs = $gpsSecs + $diff;
1342             }
1343             }
1344             # save the synchronization offset
1345 4         15 $$sync{Offset} = $gpsSecs - $imgSecs;
1346             # save this synchronization point if either timestamp had a date
1347 4 50       11 unless ($noDate) {
1348 4 100       20 $$sync{Points} or $$sync{Points} = { };
1349 4         17 $$sync{Points}{$imgSecs} = $$sync{Offset};
1350             # print verbose output
1351 4 100       16 if ($et->Options('Verbose') > 1) {
1352             # print GPS and image timestamps in UTC
1353 2         10 $et->VPrint(1, "Added Geosync point:\n",
1354             ' GPS time stamp: ', PrintFixTime($gpsSecs), "\n",
1355             ' Image date/time: ', PrintFixTime($imgSecs), "\n");
1356             }
1357             # save sorted list of image sync times if we have more than one
1358 4         11 my @times = keys %{$$sync{Points}};
  4         16  
1359 4 100       20 if (@times > 1) {
1360 2         11 @times = sort { $a <=> $b } @times;
  2         11  
1361 2         9 $$sync{Times} = \@times;
1362             }
1363             }
1364             } else {
1365             # (this is a simple time difference)
1366 1         15 my @vals = $val =~ /(?=\d|\.\d)\d*(?:\.\d*)?/g; # (allow decimal values too)
1367 1 50       7 @vals or warn("Invalid value (please refer to geotag documentation)\n"), return undef;
1368 1         7 my $secs = 0;
1369 1         4 my $mult;
1370 1         3 foreach $mult (1, 60, 3600, $secPerDay) {
1371 2         6 $secs += $mult * pop(@vals);
1372 2 100       6 last unless @vals;
1373             }
1374             # set constant sync offset
1375 1 50       10 $$sync{Offset} = $val =~ /^\s*-/ ? -$secs : $secs;
1376             }
1377 5         58 return $sync;
1378             }
1379              
1380             #------------------------------------------------------------------------------
1381             # Print fix time
1382             # Inputs: 0) time since the epoch
1383             # Returns: UTC time string with fractional seconds
1384             sub PrintFixTime($)
1385             {
1386 8     8 0 20 my $time = $_[0] + 0.0005; # round off to nearest ms
1387 8         26 my $fsec = int(($time - int($time)) * 1000);
1388 8         22 return sprintf('%s.%.3d UTC', Image::ExifTool::ConvertUnixTime($time), $fsec);
1389             }
1390              
1391             #------------------------------------------------------------------------------
1392             # Print fix information
1393             # Inputs: 0) lookup for all fix points, 1-n) list of fix times
1394             # Returns: fix string (including leading indent and trailing newline)
1395             sub PrintFix($@)
1396             {
1397 0     0 0   local $_;
1398 0           my $points = shift;
1399 0           my $str = '';
1400 0           while (@_) {
1401 0           my $time = shift;
1402 0           $str .= ' ' . PrintFixTime($time) . ' -';
1403 0           my $fix = $$points{$time};
1404 0 0         if ($fix) {
1405 0           foreach (sort keys %$fix) {
1406 0 0 0       $str .= " $_=$$fix{$_}" unless $_ eq 'time' or not defined $$fix{$_};
1407             }
1408             }
1409 0           $str .= "\n";
1410             }
1411 0           return $str;
1412             }
1413              
1414             #------------------------------------------------------------------------------
1415             1; # end
1416              
1417             __END__