Index: main/trunk/greenstone2/perllib/cpan/Image/ExifTool/Geotag.pm
===================================================================
--- main/trunk/greenstone2/perllib/cpan/Image/ExifTool/Geotag.pm	(revision 24107)
+++ main/trunk/greenstone2/perllib/cpan/Image/ExifTool/Geotag.pm	(revision 34921)
@@ -6,4 +6,13 @@
 # Revisions:    2009/04/01 - P. Harvey Created
 #               2009/09/27 - PH Added Geosync feature
+#               2009/06/25 - PH Read Garmin TCX track logs
+#               2009/09/11 - PH Read ITC GPS track logs
+#               2012/01/08 - PH Extract orientation information from PTNTHPR
+#               2012/05/08 - PH Read Winplus Beacon .TXT files
+#               2015/05/30 - PH Read Bramor gEO log files
+#               2016/07/13 - PH Added ability to geotag date/time only
+#               2019/07/02 - PH Added ability to read IMU CSV files
+#               2019/11/10 - PH Also write pitch to CameraElevationAngle
+#               2020/12/01 - PH Added ability to read DJI CSV log files
 #
 # References:   1) http://www.topografix.com/GPX/1/1/
@@ -18,8 +27,14 @@
 use vars qw($VERSION);
 use Image::ExifTool qw(:Public);
-
-$VERSION = '1.24';
-
+use Image::ExifTool::GPS;
+
+$VERSION = '1.64';
+
+sub JITTER() { return 2 }       # maximum time jitter
+
+sub GetTime($);
 sub SetGeoValues($$;$);
+sub PrintFixTime($);
+sub PrintFix($@);
 
 # XML tags that we recognize (keys are forced to lower case)
@@ -42,6 +57,13 @@
     pdop        => 'pdop',      # GPX
     sat         => 'nsats',     # GPX
+    atemp       => 'atemp',     # GPX (Garmin 550t)
     when        => 'time',      # KML
     coordinates => 'coords',    # KML
+    coord       => 'coords',    # KML, as written by Google Location History
+    begin       => 'begin',     # KML TimeSpan
+    end         => 'time',      # KML TimeSpan
+    course      => 'dir',       # (written by Arduino)
+    pitch       => 'pitch',     # (written by Arduino)
+    roll        => 'roll',      # (written by Arduino)
     # XML containers (fix is reset at the opening tag of these properties)
     wpt         => '',          # GPX
@@ -50,4 +72,28 @@
     trackpoint  => '',          # Garmin
     placemark   => '',          # KML
+);
+
+# fix information keys which must be interpolated around a circle
+my %cyclical = (lon => 1, track => 1, dir => 1, roll => 1);
+
+# fix information keys for each of our general categories
+my %fixInfoKeys = (
+   'pos'   => [ 'lat', 'lon' ],
+    track  => [ 'track', 'speed' ],
+    alt    => [ 'alt' ],
+    orient => [ 'dir', 'pitch', 'roll' ],
+    atemp  => [ 'atemp' ],
+);
+
+my %isOrient = ( dir => 1, pitch => 1, roll => 1 ); # test for orientation key
+
+# conversion factors for GPSSpeed
+my %speedConv = (
+    'K' => 1.852,       # km/h per knot
+    'M' => 1.150779448, # mph per knot
+    'k' => 'K',         # (allow lower case)
+    'm' => 'M',
+    'km/h' => 'K',      # (allow other formats)
+    'mph' => 'M',
 );
 
@@ -63,4 +109,5 @@
 #       NoDate - flag if some points have no date (ie. referenced to 1970:01:01)
 #       IsDate - flag if some points have date
+#       Has    - hash of flags for available information (track, orient, alt)
 # - the fix information hash may contain:
 #       lat    - signed latitude (required)
@@ -74,4 +121,9 @@
 #       sats   - comma-separated list of active satellites
 #       nsats  - number of active satellites
+#       track  - track heading (deg true)
+#       dir    - image direction (deg true)
+#       pitch  - pitch angle (deg)
+#       roll   - roll angle (deg)
+#       speed  - speed (knots)
 #       first  - flag set for first fix of track
 # - concatenates new data with existing track data stored in ExifTool NEW_VALUE
@@ -80,16 +132,26 @@
 {
     local ($_, $/, *EXIFTOOL_TRKFILE);
-    my ($exifTool, $val) = @_;
+    my ($et, $val) = @_;
     my ($raf, $from, $time, $isDate, $noDate, $noDateChanged, $lastDate, $dateFlarm);
-    my ($nmeaStart, $fixSecs, @fixTimes, $canCut, $cutPDOP, $cutHDOP, $cutSats, $lastFix);
-
-    unless (eval 'require Time::Local') {
+    my ($nmeaStart, $fixSecs, @fixTimes, $lastFix, %nmea, @csvHeadings);
+    my ($canCut, $cutPDOP, $cutHDOP, $cutSats, $e0, $e1, @tmp, $trackFile, $trackTime);
+
+    unless (eval { require Time::Local }) {
         return 'Geotag feature requires Time::Local installed';
     }
     # add data to existing track
-    my $geotag = $exifTool->GetNewValues('Geotag') || { };
+    my $geotag = $et->GetNewValue('Geotag') || { };
+
+    # initialize track points lookup
+    my $points = $$geotag{Points};
+    $points or $points = $$geotag{Points} = { };
+
+    # get lookup for available information types
+    my $has = $$geotag{Has};
+    $has or $has = $$geotag{Has} = { 'pos' => 1 };
+
     my $format = '';
     # is $val track log data?
-    if ($val =~ /^(\xef\xbb\xbf)?<(\?xml|gpx)\s/) {
+    if ($val =~ /^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
         $format = 'XML';
         $/ = '>';   # set input record separator to '>' for XML/GPX data
@@ -98,22 +160,31 @@
     } else {
         # $val is track file name
-        open EXIFTOOL_TRKFILE, $val or return "Error opening GPS file '$val'";
-        $raf = new File::RandomAccess(\*EXIFTOOL_TRKFILE);
-        unless ($raf->Read($_, 256)) {
-            close EXIFTOOL_TRKFILE;
-            return "Empty track file '$val'";
-        }
-        # look for XML or GPX header (might as well allow UTF-8 BOM)
-        if (/^(\xef\xbb\xbf)?<(\?xml|gpx)\s/) {
-            $format = 'XML';
-            $/ = '>';   # set input record separator to '>' for XML/GPX data
-        } elsif (/(\x0d\x0a|\x0d|\x0a)/) {
-            $/ = $1;
+        if ($et->Open(\*EXIFTOOL_TRKFILE, $val)) {
+            $trackFile = $val;
+            $raf = new File::RandomAccess(\*EXIFTOOL_TRKFILE);
+            unless ($raf->Read($_, 256)) {
+                close EXIFTOOL_TRKFILE;
+                return "Empty track file '${val}'";
+            }
+            # look for XML or GPX header (might as well allow UTF-8 BOM)
+            if (/^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
+                $format = 'XML';
+                $/ = '>';   # set input record separator to '>' for XML/GPX data
+            } elsif (/(\x0d\x0a|\x0d|\x0a)/) {
+                $/ = $1;
+            } else {
+                close EXIFTOOL_TRKFILE;
+                return "Invalid track file '${val}'";
+            }
+            $raf->Seek(0,0);
+            $from = "file '${val}'";
+        } elsif ($val eq 'DATETIMEONLY') {
+            $$geotag{DateTimeOnly} = 1;
+            $$geotag{IsDate} = 1;
+            $et->VPrint(0, 'Geotagging date/time only');
+            return $geotag;
         } else {
-            close EXIFTOOL_TRKFILE;
-            return "Invalid track file '$val'";
-        }
-        $raf->Seek(0,0);
-        $from = "file '$val'";
+            return "Error opening GPS file '${val}'";
+        }
     }
     unless ($from) {
@@ -122,12 +193,9 @@
         $from = 'data';
     }
-    # initialize track points lookup
-    my $points = $$geotag{Points};
-    $points or $points = $$geotag{Points} = { };
 
     # initialize cuts
-    my $maxHDOP = $exifTool->Options('GeoMaxHDOP');
-    my $maxPDOP = $exifTool->Options('GeoMaxPDOP');
-    my $minSats = $exifTool->Options('GeoMinSats');
+    my $maxHDOP = $et->Options('GeoMaxHDOP');
+    my $maxPDOP = $et->Options('GeoMaxPDOP');
+    my $minSats = $et->Options('GeoMinSats');
     my $isCut = $maxHDOP || $maxPDOP || $minSats;
 
@@ -136,11 +204,16 @@
     my $lastSecs = 0;
     my $fix = { };
+    my $csvDelim = $et->Options('CSVDelim');
+    $csvDelim = ',' unless defined $csvDelim;
+    my (@saveFix, $timeSpan);
     for (;;) {
         $raf->ReadLine($_) or last;
         # determine file format
         if (not $format) {
-            if (/^<(\?xml|gpx)\s/) { # look for XML or GPX header
+            if (/^<(\?xml|gpx)[\s>]/) { # look for XML or GPX header
                 $format = 'XML';
-            } elsif (/^\$(PMGNTRK|GP(RMC|GGA|GLL|GSA)),/) {
+            # check for NMEA sentence
+            # (must ONLY start with ones that have timestamps! eg. not GSA or PTNTHPR!)
+            } elsif (/^.*\$([A-Z]{2}(RMC|GGA|GLL|ZDA)|PMGNTRK),/) {
                 $format = 'NMEA';
                 $nmeaStart = $2 || $1;    # save type of first sentence
@@ -149,7 +222,7 @@
                 $nmeaStart = 'B' ;
                 next;
-            } elsif (/^HFDTE(\d{2})(\d{2})(\d{2})/) {
+            } elsif (/^HFDTE(?:DATE:)?(\d{2})(\d{2})(\d{2})/) {
                 my $year = $3 + ($3 >= 70 ? 1900 : 2000);
-                $dateFlarm = Time::Local::timegm(0,0,0,$1,$2-1,$year-1900);
+                $dateFlarm = Time::Local::timegm(0,0,0,$1,$2-1,$year);
                 $nmeaStart = 'B' ;
                 $format = 'IGC';
@@ -157,5 +230,57 @@
             } elsif ($nmeaStart and /^B/) {
                 # parse IGC fixes without a date
-                $format = 'IGC';                
+                $format = 'IGC';
+            } elsif (/^TP,D,/) {
+                $format = 'Winplus';
+            } elsif (/^\s*\d+\s+.*\sypr\s*$/ and (@tmp=split) == 12) {
+                $format = 'Bramor';
+            } elsif (((/\b(GPS)?Date/i and /\b(GPS)?(Date)?Time/i) or /\bTime\(seconds\)/i) and /\Q$csvDelim/) {
+                chomp;
+                @csvHeadings = split /\Q$csvDelim/;
+                $format = 'CSV';
+                # convert recognized headings to our parameter names
+                foreach (@csvHeadings) {
+                    my $param;
+                    s/^GPS ?//; # remove leading "GPS" to simplify regex patterns
+                    if (/^Time ?\(seconds\)$/i) { # DJI
+                        # DJI CSV log files have a column "Time(seconds)" which is seconds since
+                        # the start of the flight.  The date/time is obtained from the file name.
+                        $param = 'runtime';
+                        if ($trackFile and $trackFile =~ /(\d{4})-(\d{2})-(\d{2})[^\/]+(\d{2})-(\d{2})-(\d{2})[^\/]*$/) {
+                            $trackTime = Image::ExifTool::TimeLocal($6,$5,$4,$3,$2-1,$1);
+                            my $utc = PrintFixTime($trackTime);
+                            my $tzs = Image::ExifTool::TimeZoneString([$6,$5,$4,$3,$2-1,$1-1900],$trackTime);
+                            $et->VPrint(2, "  DJI start time:  $utc (local timezone is $tzs)\n");
+                        } else {
+                            return 'Error getting start time from file name for DJI CSV track file';
+                        }
+                    } elsif (/^Date ?Time/i) {  # ExifTool addition
+                        $param = 'datetime';
+                    } elsif (/^Date/i) {
+                        $param = 'date';
+                    } elsif (/^Time(?! ?\(text\))/i) { # (ignore DJI "Time(text)" column)
+                        $param = 'time';
+                    } elsif (/^(Pos)?Lat/i) {
+                        $param = 'lat';
+                    } elsif (/^(Pos)?Lon/i) {
+                        $param = 'lon';
+                    } elsif (/^(Pos)?Alt/i) {
+                        $param = 'alt';
+                    } elsif (/^(Angle)?(Heading|Track)/i) {
+                        $param = 'track';
+                    } elsif (/^(Angle)?Pitch/i or /^Camera ?Elevation ?Angle/i) {
+                        $param = 'pitch';
+                    } elsif (/^(Angle)?Roll/i) {
+                        $param = 'roll';
+                    }
+                    if ($param) {
+                        $et->VPrint(2, "CSV column '${_}' is $param\n");
+                        $_ = $param;
+                    } else {
+                        $et->VPrint(2, "CSV column '${_}' ignored\n");
+                        $_ = '';    # ignore this column
+                    }
+                }
+                next;
             } else {
                 # search only first 50 lines of file for a valid fix
@@ -170,10 +295,24 @@
             my ($arg, $tok, $td);
             s/\s*=\s*(['"])\s*/=$1/g;  # remove unnecessary white space in attributes
+            # Workaround for KML generated by Google Location History:
+            # lat/lon/alt are space-separated; we want commas.
+            s{(\S+)\s+(\S+)\s+(\S+)(</gx:coord>)}{$1,$2,$3$4};
             foreach $arg (split) {
-                # parse attributes (ie. GPX 'lat' and 'lon')
+                # parse attributes (eg. GPX 'lat' and 'lon')
                 # (note: ignore namespace prefixes if they exist)
                 if ($arg =~ /^(\w+:)?(\w+)=(['"])(.*?)\3/g) {
                     my $tag = $xmlTag{lc $2};
-                    $$fix{$tag} = $4 if $tag;
+                    if ($tag) {
+                        $$fix{$tag} = $4;
+                        if ($isOrient{$tag}) {
+                            $$has{orient} = 1;
+                        } elsif ($tag eq 'alt') {
+                            # validate altitude
+                            undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
+                            $$has{alt} = 1 if $$fix{alt};   # set "has altitude" flag if appropriate
+                        } elsif ($tag eq 'atemp') {
+                            $$has{atemp} = 1;
+                        }
+                    }
                 }
                 # loop through XML elements
@@ -186,4 +325,5 @@
                             # opened: start a new fix
                             $lastFix = $fix = { };
+                            undef @saveFix;
                             next;
                         } elsif ($fix and $lastFix and %$fix) {
@@ -198,8 +338,21 @@
                         if ($tag) {
                             if ($tag eq 'coords') {
+                                # save other fixes if there are more than one
+                                if (defined $$fix{lon} and defined $$fix{lat} and defined $$fix{alt}) {
+                                    push @saveFix, [ @$fix{'lon','lat','alt'} ];
+                                }
                                 # read KML "Point" coordinates
                                 @$fix{'lon','lat','alt'} = split ',', $1;
                             } else {
                                 $$fix{$tag} = $1;
+                                if ($isOrient{$tag}) {
+                                    $$has{orient} = 1;
+                                } elsif ($tag eq 'alt') {
+                                    # validate altitude
+                                    undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
+                                    $$has{alt} = 1 if $$fix{alt};   # set "has altitude" flag if appropriate
+                                } elsif ($tag eq 'atemp') {
+                                    $$has{atemp} = 1;
+                                }
                             }
                         }
@@ -209,29 +362,41 @@
                     }
                     # validate and store GPS fix
-                    if (defined $$fix{lat} and defined $$fix{lon} and $$fix{'time'} and
-                        $$fix{lat} =~ /^[+-]?\d+\.?\d*/ and
-                        $$fix{lon} =~ /^[+-]?\d+\.?\d*/ and
-                        $$fix{'time'} =~ /^(\d{4})-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(.*)/)
-                    {
-                        $time = Time::Local::timegm($6,$5,$4,$3,$2-1,$1-1900);
-                        $time += $7 if $7;  # add fractional seconds
-                        my $tz = $8;
-                        # adjust for time zone (otherwise assume UTC)
-                        # - allow timezone of +-HH:MM, +-H:MM, +-HHMM or +-HH since
-                        #   the spec is unclear about timezone format
-                        if ($tz =~ /^([-+])(\d+):(\d{2})\b/ or $tz =~ /^([-+])(\d{2})(\d{2})?\b/) {
-                            $tz = ($2 * 60 + ($3 || 0)) * 60;
-                            $tz *= -1 if $1 eq '+'; # opposite sign to change back to UTC
-                            $time += $tz;
+                    next unless defined $$fix{lat} and defined $$fix{lon} and $$fix{'time'};
+                    unless ($$fix{lat} =~ /^[+-]?\d+\.?\d*/ and $$fix{lon} =~ /^[+-]?\d+\.?\d*/) {
+                        $e0 or $et->VPrint(0, "Coordinate format error in $from\n"), $e0 = 1;
+                        next;
+                    }
+                    unless (defined($time = GetTime($$fix{'time'}))) {
+                        $e1 or $et->VPrint(0, "Timestamp format error in $from\n"), $e1 = 1;
+                        next;
+                    }
+                    $isDate = 1;
+                    $canCut= 1 if defined $$fix{pdop} or defined $$fix{hdop} or defined $$fix{nsats};
+                    # generate extra fixes assuming an equally spaced track
+                    if ($$fix{begin}) {
+                        my $begin = GetTime($$fix{begin});
+                        undef $$fix{begin};
+                        if (defined $begin and $begin < $time) {
+                            $$fix{span} = $timeSpan = ($timeSpan || 0) + 1;
+                            my $i;
+                            # duplicate the fix if there is only one so we will have
+                            # a fix and the start and end of the TimeSpan
+                            @saveFix or push @saveFix, [ @$fix{'lon','lat','alt'} ];
+                            for ($i=0; $i<@saveFix; ++$i) {
+                                my $t = $begin + ($time - $begin) * ($i / scalar(@saveFix));
+                                my %f;
+                                @f{'lon','lat','alt'} = @{$saveFix[$i]};
+                                $t += 0.001 if not $i and $$points{$t}; # (avoid dupicates)
+                                $f{span} = $timeSpan;
+                                $$points{$t} = \%f;
+                                push @fixTimes, $t;
+                            }
                         }
-                        # validate altitude
-                        undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
-                        $isDate = 1;
-                        $canCut= 1 if defined $$fix{pdop} or defined $$fix{hdop} or defined $$fix{nsats};
-                        $$points{$time} = $fix;
-                        push @fixTimes, $time;  # save times of all fixes in order
-                        $fix = { };
-                        ++$numPoints;
                     }
+                    $$points{$time} = $fix;
+                    push @fixTimes, $time;  # save times of all fixes in order
+                    $fix = { };
+                    undef @saveFix;
+                    ++$numPoints;
                 }
             }
@@ -240,15 +405,107 @@
                 /[\s>](\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(\.\d+)?)/;
             next;
+#
+# Winplus Beacon text file
+#
+        } elsif ($format eq 'Winplus') {
+            # TP,D, 44.933666667, -93.186555556, 10/26/2011, 19:07:28, 0
+            #       latitude      longitude      date        time
+            /^TP,D,\s*([-+]?\d+\.\d*),\s*([-+]?\d+\.\d*),\s*(\d+)\/(\d+)\/(\d{4}),\s*(\d+):(\d+):(\d+)/ or next;
+            $$fix{lat} = $1;
+            $$fix{lon} = $2;
+            $time = Time::Local::timegm($8,$7,$6,$4,$3-1,$5);
+DoneFix:    $isDate = 1;
+            $$points{$time} = $fix;
+            push @fixTimes, $time;
+            $fix = { };
+            ++$numPoints;
+            next;
+#
+# Bramor gEO log file
+#
+        } elsif ($format eq 'Bramor') {
+            #   1 0015   18.723675   50.672752  149 169.31 22/04/2015 07:06:55 169.31    8.88   28.07 ypr
+            #   ? index  latitude    longitude  alt track  date       time     dir       pitch  roll
+            my @parts = split ' ', $_;
+            next unless @parts == 12 and $parts[11] eq 'ypr';
+            my @d = split m{/}, $parts[6];  # date (dd/mm/YYYY)
+            my @t = split m{:}, $parts[7];  # time (HH:MM:SS)
+            next unless @d == 3 and @t == 3;
+            @$fix{qw(lat lon alt track dir pitch roll)} = @parts[2,3,4,5,8,9,10];
+            # (add the seconds afterwards in case some models have decimal seconds)
+            $time = Time::Local::timegm(0,$t[1],$t[0],$d[0],$d[1]-1,$d[2]) + $t[2];
+            # set necessary flags for extra available information
+            @$has{qw(alt track orient)} = (1,1,1);
+            goto DoneFix;   # save this fix
+        } elsif ($format eq 'CSV') {
+            chomp;
+            my @vals = split /\Q$csvDelim/;
+#
+# CSV format output of GPS/IMU POS system
+#   Date*           - date in DD/MM/YYYY format
+#   Time*           - time in HH:MM:SS.SSS format
+#   [Pos]Lat*       - latitude in decimal degrees
+#   [Pos]Lon*       - longitude in decimal degrees
+#   [Pos]Alt*       - altitude in m relative to sea level
+#   [Angle]Heading* - GPSTrack in degrees true
+#   [Angle]Pitch*   - pitch angle in degrees
+#   [Angle]Roll*    - roll angle in degrees
+# (ExifTool enhancements allow for standard tag names or descriptions as the column headings,
+#  add support for time zones and flexible coordinates, and allow new DateTime and Shift columns)
+#
+            my ($param, $date, $secs);
+            foreach $param (@csvHeadings) {
+                my $val = shift @vals;
+                last unless defined $val;
+                next unless $param;
+                if ($param eq 'datetime') {
+                    local $SIG{'__WARN__'} = sub { };
+                    my $dateTime = $et->InverseDateTime($val);
+                    if ($dateTime) {
+                        $date = Image::ExifTool::GetUnixTime($val, 2);
+                        $secs = 0;
+                    }
+                } elsif ($param eq 'date') {
+                    if ($val =~ m{^(\d{2})/(\d{2})/(\d{4})$}) {
+                        $date = Time::Local::timegm(0,0,0,$1,$2-1,$3);
+                    } elsif ($val =~ /(\d{4}).*?(\d{2}).*?(\d{2})/) {
+                        $date = Time::Local::timegm(0,0,0,$3,$2-1,$1);
+                    }
+                } elsif ($param eq 'time') {
+                    if ($val =~ /^(\d{1,2}):(\d{2}):(\d{2}(\.\d+)?).*?(([-+])(\d{1,2}):?(\d{2}))?/) {
+                        $secs = (($1 * 60) + $2) * 60 + $3;
+                        # adjust for time zone if specified
+                        $secs += ($7 * 60 + $8) * ($6 eq '-' ? 60 : -60) if $5;
+                    }
+                } elsif ($param eq 'lat' or $param eq 'lon') {
+                    $$fix{$param} = Image::ExifTool::GPS::ToDegrees($val, 1);
+                } elsif ($param eq 'runtime') {
+                    $date = $trackTime;
+                    $secs = $val;
+                } else {
+                    $$fix{$param} = $val;
+                }
+            }
+            if ($date and defined $secs and defined $$fix{lat} and defined $$fix{lon}) {
+                $time = $date + $secs;
+                $$has{alt} = 1 if defined $$fix{alt};
+                $$has{track} = 1 if defined $$fix{track};
+                $$has{orient} = 1 if defined $$fix{pitch};
+                goto DoneFix;
+            }
+            next;
         }
         my (%fix, $secs, $date, $nmea);
         if ($format eq 'NMEA') {
             # ignore unrecognized NMEA sentences
-            next unless /^\$(PMGNTRK|GP(RMC|GGA|GLL|GSA)),/;
-            $nmea = $2 || $1;
+            # (first 2 characters: GP=GPS, GL=GLONASS, GA=Gallileo, GN=combined, BD=Beidou)
+            next unless /^(.*)\$([A-Z]{2}(RMC|GGA|GLL|GSA|ZDA)|PMGNTRK|PTNTHPR),/;
+            $nmea = $3 || $2;
+            $_ = substr($_, length($1)) if length($1);
         }
 #
 # IGC (flarm) (ref 4)
 #
-        if ( $format eq 'IGC' ) {
+        if ($format eq 'IGC') {
             # B0939564531208N00557021EA007670089100207
             # BHHMMSSDDMMmmmNDDDMMmmmEAaaaaaAAAAAxxyy
@@ -262,25 +519,8 @@
             # wrap to next day if necessary
             if ($dateFlarm) {
-                $dateFlarm += $secPerDay if $secs < $lastSecs;
+                $dateFlarm += $secPerDay if $secs < $lastSecs - JITTER();
                 $date = $dateFlarm;
             }
             $nmea = 'B';
-#
-# Magellan eXplorist NMEA-like PMGNTRK sentence (optionally contains date)
-#
-        } elsif ($nmea eq 'PMGNTRK') {
-            # $PMGNTRK,4415.026,N,07631.091,W,00092,M,185031.06,A,,020409*65
-            # $PMGNTRK,ddmm.mmm,N/S,dddmm.mmm,E/W,alt,F/M,hhmmss.ss,A/V,trkname,DDMMYY*cs
-            /^\$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;
-            $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
-            $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
-            $fix{alt} = $8 eq 'M' ? $7 : $7 * 12 * 0.0254;
-            $secs = (($9 * 60) + $10) * 60 + $11;
-            $secs += $12 if $12;    # add fractional seconds
-            if (defined $15) {
-                # optional date is available in PMGNTRK sentence
-                my $year = $15 + ($15 >= 70 ? 1900 : 2000);
-                $date = Time::Local::timegm(0,0,0,$13,$14-1,$year-1900);
-            }
 #
 # NMEA RMC sentence (contains date)
@@ -288,12 +528,15 @@
         } elsif ($nmea eq 'RMC') {
             #  $GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,*25
+            #  $GPRMC,093657.007,,3652.835020,N,01053.104094,E,1.642,,290913,,,A*0F
             #  $GPRMC,hhmmss.sss,A/V,ddmm.mmmm,N/S,ddmmm.mmmm,E/W,spd(knots),dir(deg),DDMMYY,,*cs
-            /^\$GPRMC,(\d{2})(\d{2})(\d+)(\.\d+)?,A,(\d+)(\d{2}\.\d+),([NS]),(\d+)(\d{2}\.\d+),([EW]),[^,]*,[^,]*,(\d{2})(\d{2})(\d+)/ or next;
-            $fix{lat} = ($5 + $6/60) * ($7 eq 'N' ? 1 : -1);
-            $fix{lon} = ($8 + $9/60) * ($10 eq 'E' ? 1 : -1);
-            my $year = $13 + ($13 >= 70 ? 1900 : 2000);
+            /^\$[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;
+            next if $13 > 31 or $14 > 12 or $15 > 99;   # validate day/month/year
+            $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
+            $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
+            $fix{speed} = $11 if length $11;
+            $fix{track} = $12 if length $12;
+            my $year = $15 + ($15 >= 70 ? 1900 : 2000);
             $secs = (($1 * 60) + $2) * 60 + $3;
-            $secs += $4 if $4;      # add fractional seconds
-            $date = Time::Local::timegm(0,0,0,$11,$12-1,$year-1900);
+            $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
 #
 # NMEA GGA sentence (no date)
@@ -301,13 +544,11 @@
         } elsif ($nmea eq 'GGA') {
             #  $GPGGA,092204.999,4250.5589,S,14718.5084,E,1,04,24.4,19.7,M,,,,0000*1F
+            #  $GPGGA,093657.000,3652.835020,N,01053.104094,E,,8,,166.924,M,40.9,M,,*77
             #  $GPGGA,hhmmss.sss,ddmm.mmmm,N/S,dddmm.mmmm,E/W,0=invalid,sats,hdop,alt,M,...
-            /^\$GPGGA,(\d{2})(\d{2})(\d+)(\.\d+)?,(\d+)(\d{2}\.\d+),([NS]),(\d+)(\d{2}\.\d+),([EW]),[1-6],(\d+)?,(\.\d+|\d+\.?\d*)?,(-?\d+\.?\d*)?,M?,/ or next;
-            $fix{lat} = ($5 + $6/60) * ($7 eq 'N' ? 1 : -1);
-            $fix{lon} = ($8 + $9/60) * ($10 eq 'E' ? 1 : -1);
-            $fix{nsats} = $11;
-            $fix{hdop} = $12;
-            $fix{alt} = $13;
+            /^\$[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;
+            $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
+            $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
+            @fix{qw(nsats hdop alt)} = ($11,$12,$13);
             $secs = (($1 * 60) + $2) * 60 + $3;
-            $secs += $4 if $4;      # add fractional seconds
             $canCut = 1;
 #
@@ -317,9 +558,8 @@
             #  $GPGLL,4250.5589,S,14718.5084,E,092204.999,A*2D
             #  $GPGLL,ddmm.mmmm,N/S,dddmm.mmmm,E/W,hhmmss.sss,A/V*cs
-            /^\$GPGLL,(\d+)(\d{2}\.\d+),([NS]),(\d+)(\d{2}\.\d+),([EW]),(\d{2})(\d{2})(\d+)(\.\d+),A/ or next;
-            $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
-            $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
+            /^\$[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;
+            $fix{lat} = (($1 || 0) + $2/60) * ($3 eq 'N' ? 1 : -1);
+            $fix{lon} = (($4 || 0) + $5/60) * ($6 eq 'E' ? 1 : -1);
             $secs = (($7 * 60) + $8) * 60 + $9;
-            $secs += $10 if $10;    # add fractional seconds
 #
 # NMEA GSA sentence (satellite status, no date)
@@ -327,5 +567,5 @@
         } elsif ($nmea eq 'GSA') {
             # $GPGSA,A,3,04,05,,,,,,,,,,,pdop,hdop,vdop*HH
-            /^\$GPGSA,[AM],([23]),((?:\d*,){11}(?:\d*)),(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?\*/ or next;
+            /^\$[A-Z]{2}GSA,[AM],([23]),((?:\d*,){11}(?:\d*)),(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?\*/ or next;
             @fix{qw(fixtype sats pdop hdop vdop)} = ($1.'d',$2,$3,$4,$5);
             # count the number of acquired satellites
@@ -333,12 +573,52 @@
             $fix{nsats} = scalar @a;
             $canCut = 1;
+#
+# NMEA ZDA sentence (date/time, contains date)
+#
+        } elsif ($nmea eq 'ZDA') {
+            #  $GPZDA,093655.000,29,09,2013,,*58
+            #  $GPZDA,hhmmss.ss,DD,MM,YYYY,tzh,tzm (hhmmss in UTC)
+            /^\$[A-Z]{2}ZDA,(\d{2})(\d{2})(\d{2}(\.\d*)?),(\d+),(\d+),(\d+)/ or next;
+            $secs = (($1 * 60) + $2) * 60 + $3;
+            $date = Time::Local::timegm(0,0,0,$5,$6-1,$7);
+#
+# Magellan eXplorist PMGNTRK (Proprietary MaGellaN TRacK) sentence (optional date)
+#
+        } elsif ($nmea eq 'PMGNTRK') {
+            # $PMGNTRK,4415.026,N,07631.091,W,00092,M,185031.06,A,,020409*65
+            # $PMGNTRK,ddmm.mmm,N/S,dddmm.mmm,E/W,alt,F/M,hhmmss.ss,A/V,trkname,DDMMYY*cs
+            /^\$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;
+            $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
+            $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
+            $fix{alt} = $8 eq 'M' ? $7 : $7 * 12 * 0.0254;
+            $secs = (($9 * 60) + $10) * 60 + $11;
+            if (defined $15) {
+                next if $13 > 31 or $14 > 12 or $15 > 99;   # validate day/month/year
+                # optional date is available in PMGNTRK sentence
+                my $year = $15 + ($15 >= 70 ? 1900 : 2000);
+                $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
+            }
+#
+# Honeywell HMR3000 PTNTHPR (Heading Pitch Roll) sentence (no date)
+# (ref http://www.gpsarea.com/uploadfile/download/introduce/hmr3000_manual.pdf)
+#
+        } elsif ($nmea eq 'PTNTHPR') {
+            # $PTNTHPR,85.9,N,-0.9,N,0.8,N*HH
+            # $PTNTHPR,heading,heading status,pitch,pitch status,roll,roll status,*cs
+            # status: L=low alarm, M=low warning, N=normal, O=high warning
+            #         P=high alarm, C=tuning analog circuit
+            # (ignore this information on any alarm status)
+            /^\$PTNTHPR,(-?[\d.]+),[MNO],(-?[\d.]+),[MNO],(-?[\d.]+),[MNO]/ or next;
+            @fix{qw(dir pitch roll)} = ($1,$2,$3);
 
         } else {
             next;   # this shouldn't happen
         }
+        # remember the NMEA formats we successfully read
+        $nmea{$nmea} = 1;
         # use last date if necessary (and appropriate)
         if (defined $secs and not defined $date and defined $lastDate) {
             # wrap to next day if necessary
-            if ($secs < $lastSecs) {
+            if ($secs < $lastSecs - JITTER()) {
                 $lastSecs -= $secPerDay;
                 $lastDate += $secPerDay;
@@ -360,5 +640,5 @@
         }
 #
-# Add NMEA fix to our lookup
+# Add NMEA/IGC fix to our lookup
 # (this is much more complicated than it needs to be because
 #  the stupid NMEA format provides no end-of-fix indication)
@@ -426,7 +706,7 @@
     if ($noDate and not $$geotag{NoDate}) {
         if ($isDate) {
-            $exifTool->Warn('Fixes are date-less -- will use time-only interpolation');
+            $et->Warn('Fixes are date-less -- will use time-only interpolation');
         } else {
-            $exifTool->Warn('Some fixes are date-less -- may use time-only interpolation');
+            $et->Warn('Some fixes are date-less -- may use time-only interpolation');
         }
         $$geotag{NoDate} = 1;
@@ -466,24 +746,21 @@
         last;
     }
-    my $verbose = $exifTool->Options('Verbose');
+    my $verbose = $et->Options('Verbose');
     if ($verbose) {
-        my $out = $exifTool->Options('TextOut');
-        print $out "Loaded $numPoints points from GPS track log $from\n";
+        my $out = $et->Options('TextOut');
+        $format or $format = 'unknown';
+        print $out "Loaded $numPoints points from $format-format GPS track log $from\n";
         print $out "Ignored $cutPDOP points due to GeoMaxPDOP cut\n" if $cutPDOP;
         print $out "Ignored $cutHDOP points due to GeoMaxHDOP cut\n" if $cutHDOP;
         print $out "Ignored $cutSats points due to GeoMinSats cut\n" if $cutSats;
         if ($numPoints and $verbose > 1) {
-            print $out '  GPS track start: ' . Image::ExifTool::ConvertUnixTime($fixTimes[0]) . " UTC\n";
+            my @lbl = ('start:', 'end:  ');
+            # (fixes may be in reverse order in GPX files)
+            @lbl = reverse @lbl if $fixTimes[0] > $fixTimes[-1];
+            print $out "  GPS track $lbl[0] " . PrintFixTime($fixTimes[0]) . "\n";
             if ($verbose > 3) {
-                foreach $time (@fixTimes) {
-                    $fix = $$points{$time} or next;
-                    print $out '    ',Image::ExifTool::ConvertUnixTime($time),' UTC -';
-                    foreach (sort keys %$fix) {
-                        print $out " $_=$$fix{$_}" unless $_ eq 'time';
-                    }
-                    print $out "\n";
-                }
-            }
-            print $out '  GPS track end:   ' . Image::ExifTool::ConvertUnixTime($fixTimes[-1]) . " UTC\n";
+                print $out PrintFix($points, $_) foreach @fixTimes;
+            }
+            print $out "  GPS track $lbl[1] " . PrintFixTime($fixTimes[-1]) . "\n";
         }
     }
@@ -491,7 +768,34 @@
         # reset timestamp list to force it to be regenerated
         delete $$geotag{Times};
+        # set flags for available information
+        $$has{alt} = 1 if $nmea{GGA} or $nmea{PMGNTRK} or $nmea{B}; # alt
+        $$has{track} = 1 if $nmea{RMC};                             # track, speed
+        $$has{orient} = 1 if $nmea{PTNTHPR};                        # dir, pitch, roll
         return $geotag;     # success!
     }
     return "No track points found in GPS $from";
+}
+
+
+#------------------------------------------------------------------------------
+# Get floating point UTC time
+# Inputs: 0) XML time string
+# Returns: floating point time or undef on error
+sub GetTime($)
+{
+    my $timeStr = shift;
+    $timeStr =~ /^(\d{4})-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(.*)/ or return undef;
+    my $time = Time::Local::timegm($6,$5,$4,$3,$2-1,$1);
+    $time += $7 if $7;  # add fractional seconds
+    my $tz = $8;
+    # adjust for time zone (otherwise assume UTC)
+    # - allow timezone of +-HH:MM, +-H:MM, +-HHMM or +-HH since
+    #   the spec is unclear about timezone format
+    if ($tz =~ /^([-+])(\d+):(\d{2})\b/ or $tz =~ /^([-+])(\d{2})(\d{2})?\b/) {
+        $tz = ($2 * 60 + ($3 || 0)) * 60;
+        $tz *= -1 if $1 eq '+'; # opposite sign to change back to UTC
+        $time += $tz;
+    }
+    return $time;
 }
 
@@ -502,6 +806,6 @@
 sub ApplySyncCorr($$)
 {
-    my ($exifTool, $time) = @_;
-    my $sync = $exifTool->GetNewValues('Geosync');
+    my ($et, $time) = @_;
+    my $sync = $et->GetNewValue('Geosync');
     if (ref $sync eq 'HASH') {
         my $syncTimes = $$sync{Times};
@@ -511,9 +815,5 @@
             while ($i1 > $i0 + 1) {
                 my $pt = int(($i0 + $i1) / 2);
-                if ($time < $$syncTimes[$pt]) {
-                    $i1 = $pt;
-                } else {
-                    $i0 = $pt;
-                }
+                ($time < $$syncTimes[$pt] ? $i1 : $i0) = $pt;
             }
             my ($t0, $t1) = ($$syncTimes[$i0], $$syncTimes[$i1]);
@@ -530,4 +830,59 @@
     }
     return $sync;
+}
+
+#------------------------------------------------------------------------------
+# Scan outwards for a fix containing the requested parameter
+# Inputs: 0) name of fix parameter, 1) reference to list of fix times,
+#         2) reference to fix points hash, 3) index of starting time,
+#         4) direction to scan (-1 or +1), 5) maximum time difference
+# Returns: 0) time for fix containing requested information (or undef)
+#          1) the corresponding fix, 2) the value of the requested fix parameter
+sub ScanOutwards($$$$$$)
+{
+    my ($key, $times, $points, $i, $dir, $maxSecs) = @_;
+    my $t0 = $$times[$i];
+    for (;;) {
+        $i += $dir;
+        last if $i < 0 or $i >= scalar @$times;
+        my $t = $$times[$i];
+        last if abs($t - $t0) > $maxSecs;   # don't look too far
+        my $p = $$points{$t};
+        my $v = $$p{$key};
+        return($t,$p,$v) if defined $v;
+    }
+    return();
+}
+
+#------------------------------------------------------------------------------
+# Find nearest fix containing the specified parameter
+# Inputs: 0) ExifTool ref, 1) name of fix parameter, 2) reference to list of fix times,
+#         3) reference to fix points hash, 4) index of starting time,
+#         5) direction to scan (-1, +1 or undef), 6) maximum time difference
+# Returns: reference to fix hash or undef
+sub FindFix($$$$$$$)
+{
+    my ($et, $key, $times, $points, $i, $dir, $maxSecs) = @_;
+    my ($t,$p);
+    if ($dir) {
+        ($t,$p) = ScanOutwards($key, $times, $points, $i, $dir, $maxSecs);
+    } else {
+        my ($t1, $p1) = ScanOutwards($key, $times, $points, $i, -1, $maxSecs);
+        my ($t2, $p2) = ScanOutwards($key, $times, $points, $i, 1, $maxSecs);
+        if (defined $t1) {
+            if (defined $t2) {
+                # both surrounding points are valid, so take the closest one
+                ($t, $p) = ($t - $t1 < $t2 - $t) ? ($t1, $p1) : ($t2, $p2);
+            } else {
+                ($t, $p) = ($t1, $p1);
+            }
+        } elsif (defined $t2) {
+            ($t, $p) = ($t2, $p2);
+        }
+    }
+    if (defined $p and $$et{OPTIONS}{Verbose} > 2) {
+        $et->VPrint(2, "  Taking $key from fix:\n", PrintFix($points, $t))
+    }
+    return $p;
 }
 
@@ -541,7 +896,8 @@
 {
     local $_;
-    my ($exifTool, $val, $writeGroup) = @_;
-    my $geotag = $exifTool->GetNewValues('Geotag');
-    my ($fix, $time, $fsec, $noDate, $secondTry);
+    my ($et, $val, $writeGroup) = @_;
+    my $geotag = $et->GetNewValue('Geotag');
+    my $verbose = $et->Options('Verbose');
+    my ($fix, $time, $fsec, $noDate, $secondTry, $iExt, $iDir);
 
     # remove date if none of our fixes had date information
@@ -549,11 +905,14 @@
 
     # maximum time (sec) from nearest GPS fix when position is still considered valid
-    my $geoMaxIntSecs = $exifTool->Options('GeoMaxIntSecs');
-    my $geoMaxExtSecs = $exifTool->Options('GeoMaxExtSecs');
+    my $geoMaxIntSecs = $et->Options('GeoMaxIntSecs');
+    my $geoMaxExtSecs = $et->Options('GeoMaxExtSecs');
+
     # use 30 minutes for a default
     defined $geoMaxIntSecs or $geoMaxIntSecs = 1800;
     defined $geoMaxExtSecs or $geoMaxExtSecs = 1800;
 
+    my $times = $$geotag{Times};
     my $points = $$geotag{Points};
+    my $has = $$geotag{Has};
     my $err = '';
     # loop to try date/time value first, then time-only value
@@ -563,5 +922,4 @@
             last;
         }
-        my $times = $$geotag{Times};
         unless ($times) {
             # generate sorted timestamp list for binary search
@@ -569,9 +927,9 @@
             $times = $$geotag{Times} = \@times;
         }
-        unless ($times and @$times) {
+        unless ($times and @$times or $$geotag{DateTimeOnly}) {
             $err = 'GPS track is empty';
             last;
         }
-        unless (eval 'require Time::Local') {
+        unless (eval { require Time::Local }) {
             $err = 'Geotag feature requires Time::Local installed';
             last;
@@ -593,5 +951,5 @@
         }
         if ($tz) {
-            $time = Time::Local::timegm($sec,$min,$hr,$day,$mon-1,$year-1900);
+            $time = Time::Local::timegm($sec,$min,$hr,$day,$mon-1,$year);
             # use timezone from date/time value
             if ($tz ne 'Z') {
@@ -601,5 +959,5 @@
         } else {
             # assume local timezone
-            $time = Image::ExifTool::TimeLocal($sec,$min,$hr,$day,$mon-1,$year-1900);
+            $time = Image::ExifTool::TimeLocal($sec,$min,$hr,$day,$mon-1,$year);
         }
         # add fractional seconds
@@ -607,30 +965,47 @@
 
         # bring UTC time back to Jan. 1 if no date is given
-        $time %= $secPerDay if $noDate;
+        # (don't use '%' operator here because it drops fractional seconds)
+        $time -= int($time / $secPerDay) * $secPerDay if $noDate;
 
         # apply time synchronization if available
-        my $sync = ApplySyncCorr($exifTool, $time);
+        my $sync = ApplySyncCorr($et, $time);
 
         # save fractional seconds string
         $fsec = ($time =~ /(\.\d+)$/) ? $1 : '';
 
-        if ($exifTool->Options('Verbose') > 1 and not $secondTry) {
-            my $out = $exifTool->Options('TextOut');
-            my $str = "$fsec UTC";
+        if ($et->Options('Verbose') > 1 and not $secondTry) {
+            my $out = $et->Options('TextOut');
+            my $str = '';
             $str .= sprintf(" (incl. Geosync offset of %+.3f sec)", $sync) if defined $sync;
-            print $out '  Geotime value:   ' . Image::ExifTool::ConvertUnixTime(int $time) . "$str\n";
-        }
+            unless ($tz) {
+                my $tzs = Image::ExifTool::TimeZoneString([$sec,$min,$hr,$day,$mon-1,$year-1900],$time);
+                $str .= " (local timezone is $tzs)";
+            }
+            print $out '  Geotime value:   ' . PrintFixTime($time) . "$str\n";
+        }
+        if (not $times or not @$times) {
+            $fix = { }; # dummy fix to geotag date/time only
         # interpolate GPS track at $time
-        if ($time < $$times[0]) {
+        } elsif ($time < $$times[0]) {
             if ($time < $$times[0] - $geoMaxExtSecs) {
                 $err or $err = 'Time is too far before track';
+                $et->VPrint(2, '  Track start:     ', PrintFixTime($$times[0]), "\n") if $verbose > 2;
+                $fix = { } if $$geotag{DateTimeOnly};
             } else {
                 $fix = $$points{$$times[0]};
+                $iExt = 0;  $iDir = 1;
+                $et->VPrint(2, "  Taking pos from fix:\n",
+                    PrintFix($points, $$times[0])) if $verbose > 2;
             }
         } elsif ($time > $$times[-1]) {
             if ($time > $$times[-1] + $geoMaxExtSecs) {
                 $err or $err = 'Time is too far beyond track';
+                $et->VPrint(2, '  Track end:       ', PrintFixTime($$times[-1]), "\n") if $verbose > 2;
+                $fix = { } if $$geotag{DateTimeOnly};
             } else {
                 $fix = $$points{$$times[-1]};
+                $iExt = $#$times;  $iDir = -1;
+                $et->VPrint(2, "  Taking pos from fix:\n",
+                    PrintFix($points, $$times[-1])) if $verbose > 2;
             }
         } else {
@@ -639,9 +1014,5 @@
             while ($i1 > $i0 + 1) {
                 my $pt = int(($i0 + $i1) / 2);
-                if ($time < $$times[$pt]) {
-                    $i1 = $pt;
-                } else {
-                    $i0 = $pt;
-                }
+                ($time < $$times[$pt] ? $i1 : $i0) = $pt;
             }
             # do linear interpolation for position
@@ -650,22 +1021,87 @@
             my $p1 = $$points{$t1};
             # check to see if we are extrapolating before the first entry in a track
-            my $maxSecs = $$p1{first} ? $geoMaxExtSecs : $geoMaxIntSecs;
+            my $maxSecs = ($$p1{first} and $geoMaxIntSecs) ? $geoMaxExtSecs : $geoMaxIntSecs;
             # don't interpolate if fixes are too far apart
-            if ($t1 - $t0 > $maxSecs) {
+            # (but always interpolate fixes inside the same TimeSpan)
+            if ($t1 - $t0 > $maxSecs and (not $$p1{span} or not $$points{$t0}{span} or
+                $$p1{span} != $$points{$t0}{span}))
+            {
                 # treat as an extrapolation -- use nearest fix if close enough
-                my $tn = ($time - $t0 < $t1 - $time) ? $t0 : $t1;
+                my $tn;
+                if ($time - $t0 < $t1 - $time) {
+                    $tn = $t0;
+                    $iExt = $i0;
+                } else {
+                    $tn = $t1;
+                    $iExt = $i1;
+                }
                 if (abs($time - $tn) > $geoMaxExtSecs) {
                     $err or $err = 'Time is too far from nearest GPS fix';
+                    $et->VPrint(2, '  Nearest fix:     ', PrintFixTime($tn), "\n") if $verbose > 2;
+                    $fix = { } if $$geotag{DateTimeOnly};
                 } else {
                     $fix = $$points{$tn};
+                    $et->VPrint(2, "  Taking pos from fix:\n",
+                        PrintFix($points, $tn)) if $verbose > 2;
                 }
             } else {
-                my $f = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
+                my $f0 = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
                 my $p0 = $$points{$t0};
+                $et->VPrint(2, "  Interpolating between fixes (f=$f0):\n",
+                    PrintFix($points, $t0, $t1)) if $verbose > 2;
                 $fix = { };
-                # loop through latitude, longitude, and altitude if available
-                foreach (qw(lat lon alt)) {
-                    next unless defined $$p0{$_} and defined $$p1{$_};
-                    $$fix{$_} = $$p1{$_} * $f + $$p0{$_} * (1 - $f);
+                # loop through available fix information categories
+                # (pos, track, alt, orient)
+                my ($category, $key);
+Category:       foreach $category (qw{pos track alt orient atemp}) {
+                    next unless $$has{$category};
+                    my ($f, $p0b, $p1b, $f0b);
+                    # loop through specific fix information keys
+                    # (lat, lon, alt, track, speed, dir, pitch, roll)
+                    foreach $key (@{$fixInfoKeys{$category}}) {
+                        my $v0 = $$p0{$key};
+                        my $v1 = $$p1{$key};
+                        if (defined $v0 and defined $v1) {
+                            $f = $f0;
+                        } elsif (defined $f0b) {
+                            $v0 = $$p0b{$key};
+                            $v1 = $$p1b{$key};
+                            next unless defined $v0 and defined $v1;
+                            $f = $f0b;
+                        } else {
+                            # scan outwards looking for fixes with the required information
+                            # (NOTE: SHOULD EVENTUALLY DO THIS FOR EXTRAPOLATION TOO!)
+                            my ($t0b, $t1b);
+                            if (defined $v0) {
+                                $t0b = $t0;  $p0b = $p0;
+                            } else {
+                                ($t0b,$p0b,$v0) = ScanOutwards($key,$times,$points,$i0,-1,$maxSecs);
+                                next Category unless defined $t0b;
+                            }
+                            if (defined $v1) {
+                                $t1b = $t1;  $p1b = $p1;
+                            } else {
+                                ($t1b,$p1b,$v1) = ScanOutwards($key,$times,$points,$i1,1,$maxSecs);
+                                next Category unless defined $t1b;
+                            }
+                            # re-calculate the interpolation factor
+                            $f = $f0b = $t1b == $t0b ? 0 : ($time - $t0b) / ($t1b - $t0b);
+                            $et->VPrint(2, "  Interpolating $category between fixes (f=$f):\n",
+                                PrintFix($points, $t0b, $t1b)) if $verbose > 2;
+                        }
+                        # must interpolate cyclical values differently
+                        if ($cyclical{$key} and abs($v1 - $v0) > 180) {
+                            # the acute angle spans the discontinuity, so add
+                            # 360 degrees to the smaller angle before interpolating
+                            $v0 < $v1 ? $v0 += 360 : $v1 += 360;
+                            $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
+                            # longitude and roll ranges are -180 to 180, others are 0 to 360
+                            my $max = ($key eq 'lon' or $key eq 'roll') ? 180 : 360;
+                            $$fix{$key} -= 360 if $$fix{$key} >= $max;
+                        } else {
+                            # simple linear interpolation
+                            $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
+                        }
+                    }
                 }
             }
@@ -690,4 +1126,10 @@
             $gpsAlt = abs $$fix{alt};
             $gpsAltRef = ($$fix{alt} < 0 ? 1 : 0);
+        } elsif ($$has{alt} and defined $iExt) {
+            my $tFix = FindFix($et,'alt',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
+            if ($tFix) {
+                $gpsAlt = abs $$tFix{alt};
+                $gpsAltRef = ($$tFix{alt} < 0 ? 1 : 0);
+            }
         }
         # set new GPS tag values (EXIF, or XMP if write group is 'xmp')
@@ -700,13 +1142,59 @@
         }
         # (capture error messages by calling SetNewValue in list context)
-        @r = $exifTool->SetNewValue(GPSLatitude => $$fix{lat}, %opts);
-        @r = $exifTool->SetNewValue(GPSLongitude => $$fix{lon}, %opts);
-        @r = $exifTool->SetNewValue(GPSAltitude => $gpsAlt, %opts);
-        @r = $exifTool->SetNewValue(GPSAltitudeRef => $gpsAltRef, %opts);
+        @r = $et->SetNewValue(GPSLatitude => $$fix{lat}, %opts);
+        @r = $et->SetNewValue(GPSLongitude => $$fix{lon}, %opts);
+        @r = $et->SetNewValue(GPSAltitude => $gpsAlt, %opts);
+        @r = $et->SetNewValue(GPSAltitudeRef => $gpsAltRef, %opts);
+        if ($$has{track}) {
+            my $tFix = $fix;
+            if (not defined $$fix{track} and defined $iExt) {
+                my $p = FindFix($et,'track',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
+                $tFix = $p if $p;
+            }
+            @r = $et->SetNewValue(GPSTrack => $$tFix{track}, %opts);
+            @r = $et->SetNewValue(GPSTrackRef => (defined $$tFix{track} ? 'T' : undef), %opts);
+            my ($spd, $ref);
+            if (defined($spd = $$tFix{speed})) {
+                $ref = $$et{OPTIONS}{GeoSpeedRef};
+                if ($ref and defined $speedConv{$ref}) {
+                    $ref = $speedConv{$ref} if $speedConv{$speedConv{$ref}};
+                    $spd *= $speedConv{$ref};
+                } else {
+                    $ref = 'N';     # knots by default
+                }
+            }
+            @r = $et->SetNewValue(GPSSpeed => $spd, %opts);
+            @r = $et->SetNewValue(GPSSpeedRef => $ref, %opts);
+        }
+        if ($$has{orient}) {
+            my $tFix = $fix;
+            if (not defined $$fix{dir} and defined $iExt) {
+                my $p = FindFix($et,'dir',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
+                $tFix = $p if $p;
+            }
+            @r = $et->SetNewValue(GPSImgDirection => $$tFix{dir}, %opts);
+            @r = $et->SetNewValue(GPSImgDirectionRef => (defined $$tFix{dir} ? 'T' : undef), %opts);
+            @r = $et->SetNewValue(CameraElevationAngle => $$tFix{pitch}, %opts);
+            # Note: GPSPitch and GPSRoll are non-standard, and must be user-defined
+            @r = $et->SetNewValue(GPSPitch => $$tFix{pitch}, %opts);
+            @r = $et->SetNewValue(GPSRoll => $$tFix{roll}, %opts);
+        }
+        if ($$has{atemp}) {
+            my $tFix = $fix;
+            if (not defined $$fix{atemp} and defined $iExt) {
+                # (not all fixes have atemp, so try interpolating specifically for this)
+                my $p = FindFix($et,'atemp',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
+                $tFix = $p if $p;
+            }
+            @r = $et->SetNewValue(AmbientTemperature => $$tFix{atemp}, %opts);
+        }
         unless ($xmp) {
-            @r = $exifTool->SetNewValue(GPSLatitudeRef => ($$fix{lat} > 0 ? 'N' : 'S'), %opts);
-            @r = $exifTool->SetNewValue(GPSLongitudeRef => ($$fix{lon} > 0 ? 'E' : 'W'), %opts);
-            @r = $exifTool->SetNewValue(GPSDateStamp => $gpsDate, %opts);
-            @r = $exifTool->SetNewValue(GPSTimeStamp => $gpsTime, %opts);
+            my ($latRef, $lonRef);
+            $latRef = ($$fix{lat} > 0 ? 'N' : 'S') if defined $$fix{lat};
+            $lonRef = ($$fix{lon} > 0 ? 'E' : 'W') if defined $$fix{lon};
+            @r = $et->SetNewValue(GPSLatitudeRef => $latRef, %opts);
+            @r = $et->SetNewValue(GPSLongitudeRef => $lonRef, %opts);
+            @r = $et->SetNewValue(GPSDateStamp => $gpsDate, %opts);
+            @r = $et->SetNewValue(GPSTimeStamp => $gpsTime, %opts);
             # set options to edit XMP:GPSDateTime only if it already exists
             $opts{EditOnly} = 1;
@@ -714,15 +1202,19 @@
         }
         unless ($exif) {
-            @r = $exifTool->SetNewValue(GPSDateTime => "$gpsDate $gpsTime", %opts);
+            @r = $et->SetNewValue(GPSDateTime => "$gpsDate $gpsTime", %opts);
         }
     } else {
-        my %opts;
+        my %opts = ( IgnorePermanent => 1 );
         $opts{Replace} = 2 if defined $val; # remove existing new values
         $opts{Group} = $writeGroup if $writeGroup;
+
         # reset any GPS values we might have already set
         foreach (qw(GPSLatitude GPSLatitudeRef GPSLongitude GPSLongitudeRef
-                    GPSAltitude GPSAltitudeRef GPSDateStamp GPSTimeStamp GPSDateTime))
+                    GPSAltitude GPSAltitudeRef GPSDateStamp GPSTimeStamp GPSDateTime
+                    GPSTrack GPSTrackRef GPSSpeed GPSSpeedRef GPSImgDirection
+                    GPSImgDirectionRef GPSPitch GPSRoll CameraElevationAngle
+                    AmbientTemperature))
         {
-            my @r = $exifTool->SetNewValue($_, undef, %opts);
+            my @r = $et->SetNewValue($_, undef, %opts);
         }
     }
@@ -735,20 +1227,19 @@
 #         1) time difference string ("[+-]DD MM:HH:SS.ss"), geosync'd file name,
 #            "GPSTIME@IMAGETIME", or "GPSTIME@FILENAME"
-# Returns: geosync hash
+# Returns: geosync hash:
+#           Offset = Offset in seconds for latest synchronization (GPS - image time)
+#           Points = hash of all sync offsets keyed by image times in seconds
+#           Times = sorted list of image synchronization times (keys in Points hash)
 # Notes: calling this routine with more than one geosync'd file causes time drift
 #        correction to be implemented
 sub ConvertGeosync($$)
 {
-    my ($exifTool, $val) = @_;
-    my $sync = $exifTool->GetNewValues('Geosync') || { };
+    my ($et, $val) = @_;
+    my $sync = $et->GetNewValue('Geosync') || { };
     my ($syncFile, $gpsTime, $imgTime);
 
     if ($val =~ /(.*?)\@(.*)/) {
         $gpsTime = $1;
-        if (-f $2) {
-            $syncFile = $2;
-        } else {
-            $imgTime = $2;
-        }
+        (-f $2 ? $syncFile : $imgTime) = $2;
     # (take care because "-f '1:30'" crashes ActivePerl 5.10)
     } elsif ($val !~ /^\d/ or $val !~ /:/) {
@@ -764,15 +1255,18 @@
                                  'GPSDateTime', 'GPSTimeStamp');
             $$info{Error} and warn("$$info{Err}\n"), return undef;
-            $gpsTime or $gpsTime = $$info{GPSDateTime} || $$info{GPSTimeStamp};
+            unless ($gpsTime) {
+                $gpsTime = $$info{GPSDateTime} || $$info{GPSTimeStamp};
+                $gpsTime .= 'Z' if $gpsTime and not $$info{GPSDateTime};
+            }
+            $gpsTime or warn("No GPSTimeStamp in '$syncFile\n"), return undef;
             my $tag;
             foreach $tag (@timeTags) {
                 if ($$info{$tag}) {
                     $imgTime = $$info{$tag};
-                    $exifTool->VPrint(2, "Geosyncing with $tag from '$syncFile'\n");
+                    $et->VPrint(2, "Geosyncing with $tag from '${syncFile}'\n");
                     last;
                 }
             }
-            $gpsTime or warn("No GPSTimeStamp in '$syncFile\n"), return undef;
-            $imgTime or warn("No image timestamp in '$syncFile'\n"), return undef;
+            $imgTime or warn("No image timestamp in '${syncFile}'\n"), return undef;
         }
         # add date to date-less timestamps
@@ -799,7 +1293,7 @@
         # calculate Unix seconds since the epoch
         my $imgSecs = Image::ExifTool::GetUnixTime($imgDateTime, 1);
-        defined $imgSecs or warn("Invalid image time '$imgTime'\n"), return undef;
+        defined $imgSecs or warn("Invalid image time '${imgTime}'\n"), return undef;
         my $gpsSecs = Image::ExifTool::GetUnixTime($gpsDateTime, 1);
-        defined $gpsSecs or warn("Invalid GPS time '$gpsTime'\n"), return undef;
+        defined $gpsSecs or warn("Invalid GPS time '${gpsTime}'\n"), return undef;
         # add fractional seconds
         $gpsSecs += $1 if $gpsTime =~ /(\.\d+)/;
@@ -823,13 +1317,9 @@
             $$sync{Points}{$imgSecs} = $$sync{Offset};
             # print verbose output
-            if ($exifTool->Options('Verbose') > 1) {
+            if ($et->Options('Verbose') > 1) {
                 # print GPS and image timestamps in UTC
-                my $gps = Image::ExifTool::ConvertUnixTime($gpsSecs);
-                my $img = Image::ExifTool::ConvertUnixTime($imgSecs);
-                $gps .= $1 if $gpsTime =~ /(\.\d+)/;
-                $img .= $1 if $imgTime =~ /(\.\d+)/;
-                $exifTool->VPrint(1, "Added Geosync point:\n",
-                                     "  GPS time stamp:  $gps UTC\n",
-                                     "  Image date/time: $img UTC\n");
+                $et->VPrint(1, "Added Geosync point:\n",
+                               '  GPS time stamp:  ', PrintFixTime($gpsSecs), "\n",
+                               '  Image date/time: ', PrintFixTime($imgSecs), "\n");
             }
             # save sorted list of image sync times if we have more than one
@@ -857,4 +1347,38 @@
 
 #------------------------------------------------------------------------------
+# Print fix time
+# Inputs: 0) time since the epoch
+# Returns: UTC time string with fractional seconds
+sub PrintFixTime($)
+{
+    my $time = $_[0] + 0.0005;  # round off to nearest ms
+    my $fsec = int(($time - int($time)) * 1000);
+    return sprintf('%s.%.3d UTC', Image::ExifTool::ConvertUnixTime($time), $fsec);
+}
+
+#------------------------------------------------------------------------------
+# Print fix information
+# Inputs: 0) lookup for all fix points, 1-n) list of fix times
+# Returns: fix string (including leading indent and trailing newline)
+sub PrintFix($@)
+{
+    local $_;
+    my $points = shift;
+    my $str = '';
+    while (@_) {
+        my $time = shift;
+        $str .= '    ' . PrintFixTime($time) . ' -';
+        my $fix = $$points{$time};
+        if ($fix) {
+            foreach (sort keys %$fix) {
+                $str .= " $_=$$fix{$_}" unless $_ eq 'time' or not defined $$fix{$_};
+            }
+        }
+        $str .= "\n";
+    }
+    return $str;
+}
+
+#------------------------------------------------------------------------------
 1;  # end
 
@@ -873,6 +1397,7 @@
 This module loads GPS track logs, interpolates to determine position based
 on time, and sets new GPS values for geotagging images.  Currently supported
-formats are GPX, NMEA RMC/GGA/GLL, KML, IGC, Garmin XML and TCX, and
-Magellan PMGNTRK.
+formats are GPX, NMEA RMC/GGA/GLL, KML, IGC, Garmin XML and TCX, Magellan
+PMGNTRK, Honeywell PTNTHPR, Winplus Beacon text, IMU CSV, DJI CSV, and
+Bramor gEO log files.
 
 Methods in this module should not be called directly.  Instead, the Geotag
@@ -881,7 +1406,12 @@
 in the tag name documentation).
 
+=head1 NOTES
+
+To take advantage of attitude information in the PTNTHPR NMEA sentence, the
+user-defined tag GPSRoll, must be active.
+
 =head1 AUTHOR
 
-Copyright 2003-2011, Phil Harvey (phil at owl.phy.queensu.ca)
+Copyright 2003-2021, Phil Harvey (philharvey66 at gmail.com)
 
 This library is free software; you can redistribute it and/or modify it
@@ -912,3 +1442,2 @@
 
 =cut
-
