Ignore:
Timestamp:
2021-02-26T19:39:51+13:00 (3 years ago)
Author:
anupama
Message:

Committing the improvements to EmbeddedMetaPlugin's processing of Keywords vs other metadata fields. Keywords were literally stored as arrays of words rather than phrases in PDFs (at least in Diego's sample PDF), whereas other meta fields like Subjects and Creators stored them as arrays of phrases. To get both to work, Kathy updated EXIF to a newer version, to retrieve the actual EXIF values stored in the PDF. And Kathy and Dr Bainbridge came up with a new option that I added called apply_join_before_split_to_metafields that's a regex which can list the metadata fields to apply the join_before_split to and whcih previously always got applied to all metadata fields. Now it's applied to any *Keywords metafields by default, as that's the metafield we have experience of that behaves differently to the others, as it stores by word instead of phrases. Tested on Diego's sample PDF. Diego has double-checked it to works on his sample PDF too, setting the split char to ; and turning on the join_before_split and leaving apply_join_before_split_to_metafields at its default of .*Keywords. File changes are strings.properties for the tooltip, the plugin introducing the option and working with it and Kathy's EXIF updates affecting cpan/File and cpan/Image.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • main/trunk/greenstone2/perllib/cpan/Image/ExifTool/Geotag.pm

    r24107 r34921  
    66# Revisions:    2009/04/01 - P. Harvey Created
    77#               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
    817#
    918# References:   1) http://www.topografix.com/GPX/1/1/
     
    1827use vars qw($VERSION);
    1928use Image::ExifTool qw(:Public);
    20 
    21 $VERSION = '1.24';
    22 
     29use Image::ExifTool::GPS;
     30
     31$VERSION = '1.64';
     32
     33sub JITTER() { return 2 }       # maximum time jitter
     34
     35sub GetTime($);
    2336sub SetGeoValues($$;$);
     37sub PrintFixTime($);
     38sub PrintFix($@);
    2439
    2540# XML tags that we recognize (keys are forced to lower case)
     
    4257    pdop        => 'pdop',      # GPX
    4358    sat         => 'nsats',     # GPX
     59    atemp       => 'atemp',     # GPX (Garmin 550t)
    4460    when        => 'time',      # KML
    4561    coordinates => 'coords',    # KML
     62    coord       => 'coords',    # KML, as written by Google Location History
     63    begin       => 'begin',     # KML TimeSpan
     64    end         => 'time',      # KML TimeSpan
     65    course      => 'dir',       # (written by Arduino)
     66    pitch       => 'pitch',     # (written by Arduino)
     67    roll        => 'roll',      # (written by Arduino)
    4668    # XML containers (fix is reset at the opening tag of these properties)
    4769    wpt         => '',          # GPX
     
    5072    trackpoint  => '',          # Garmin
    5173    placemark   => '',          # KML
     74);
     75
     76# fix information keys which must be interpolated around a circle
     77my %cyclical = (lon => 1, track => 1, dir => 1, roll => 1);
     78
     79# fix information keys for each of our general categories
     80my %fixInfoKeys = (
     81   'pos'   => [ 'lat', 'lon' ],
     82    track  => [ 'track', 'speed' ],
     83    alt    => [ 'alt' ],
     84    orient => [ 'dir', 'pitch', 'roll' ],
     85    atemp  => [ 'atemp' ],
     86);
     87
     88my %isOrient = ( dir => 1, pitch => 1, roll => 1 ); # test for orientation key
     89
     90# conversion factors for GPSSpeed
     91my %speedConv = (
     92    'K' => 1.852,       # km/h per knot
     93    'M' => 1.150779448, # mph per knot
     94    'k' => 'K',         # (allow lower case)
     95    'm' => 'M',
     96    'km/h' => 'K',      # (allow other formats)
     97    'mph' => 'M',
    5298);
    5399
     
    63109#       NoDate - flag if some points have no date (ie. referenced to 1970:01:01)
    64110#       IsDate - flag if some points have date
     111#       Has    - hash of flags for available information (track, orient, alt)
    65112# - the fix information hash may contain:
    66113#       lat    - signed latitude (required)
     
    74121#       sats   - comma-separated list of active satellites
    75122#       nsats  - number of active satellites
     123#       track  - track heading (deg true)
     124#       dir    - image direction (deg true)
     125#       pitch  - pitch angle (deg)
     126#       roll   - roll angle (deg)
     127#       speed  - speed (knots)
    76128#       first  - flag set for first fix of track
    77129# - concatenates new data with existing track data stored in ExifTool NEW_VALUE
     
    80132{
    81133    local ($_, $/, *EXIFTOOL_TRKFILE);
    82     my ($exifTool, $val) = @_;
     134    my ($et, $val) = @_;
    83135    my ($raf, $from, $time, $isDate, $noDate, $noDateChanged, $lastDate, $dateFlarm);
    84     my ($nmeaStart, $fixSecs, @fixTimes, $canCut, $cutPDOP, $cutHDOP, $cutSats, $lastFix);
    85 
    86     unless (eval 'require Time::Local') {
     136    my ($nmeaStart, $fixSecs, @fixTimes, $lastFix, %nmea, @csvHeadings);
     137    my ($canCut, $cutPDOP, $cutHDOP, $cutSats, $e0, $e1, @tmp, $trackFile, $trackTime);
     138
     139    unless (eval { require Time::Local }) {
    87140        return 'Geotag feature requires Time::Local installed';
    88141    }
    89142    # add data to existing track
    90     my $geotag = $exifTool->GetNewValues('Geotag') || { };
     143    my $geotag = $et->GetNewValue('Geotag') || { };
     144
     145    # initialize track points lookup
     146    my $points = $$geotag{Points};
     147    $points or $points = $$geotag{Points} = { };
     148
     149    # get lookup for available information types
     150    my $has = $$geotag{Has};
     151    $has or $has = $$geotag{Has} = { 'pos' => 1 };
     152
    91153    my $format = '';
    92154    # is $val track log data?
    93     if ($val =~ /^(\xef\xbb\xbf)?<(\?xml|gpx)\s/) {
     155    if ($val =~ /^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
    94156        $format = 'XML';
    95157        $/ = '>';   # set input record separator to '>' for XML/GPX data
     
    98160    } else {
    99161        # $val is track file name
    100         open EXIFTOOL_TRKFILE, $val or return "Error opening GPS file '$val'";
    101         $raf = new File::RandomAccess(\*EXIFTOOL_TRKFILE);
    102         unless ($raf->Read($_, 256)) {
    103             close EXIFTOOL_TRKFILE;
    104             return "Empty track file '$val'";
    105         }
    106         # look for XML or GPX header (might as well allow UTF-8 BOM)
    107         if (/^(\xef\xbb\xbf)?<(\?xml|gpx)\s/) {
    108             $format = 'XML';
    109             $/ = '>';   # set input record separator to '>' for XML/GPX data
    110         } elsif (/(\x0d\x0a|\x0d|\x0a)/) {
    111             $/ = $1;
     162        if ($et->Open(\*EXIFTOOL_TRKFILE, $val)) {
     163            $trackFile = $val;
     164            $raf = new File::RandomAccess(\*EXIFTOOL_TRKFILE);
     165            unless ($raf->Read($_, 256)) {
     166                close EXIFTOOL_TRKFILE;
     167                return "Empty track file '${val}'";
     168            }
     169            # look for XML or GPX header (might as well allow UTF-8 BOM)
     170            if (/^(\xef\xbb\xbf)?<(\?xml|gpx)[\s>]/) {
     171                $format = 'XML';
     172                $/ = '>';   # set input record separator to '>' for XML/GPX data
     173            } elsif (/(\x0d\x0a|\x0d|\x0a)/) {
     174                $/ = $1;
     175            } else {
     176                close EXIFTOOL_TRKFILE;
     177                return "Invalid track file '${val}'";
     178            }
     179            $raf->Seek(0,0);
     180            $from = "file '${val}'";
     181        } elsif ($val eq 'DATETIMEONLY') {
     182            $$geotag{DateTimeOnly} = 1;
     183            $$geotag{IsDate} = 1;
     184            $et->VPrint(0, 'Geotagging date/time only');
     185            return $geotag;
    112186        } else {
    113             close EXIFTOOL_TRKFILE;
    114             return "Invalid track file '$val'";
    115         }
    116         $raf->Seek(0,0);
    117         $from = "file '$val'";
     187            return "Error opening GPS file '${val}'";
     188        }
    118189    }
    119190    unless ($from) {
     
    122193        $from = 'data';
    123194    }
    124     # initialize track points lookup
    125     my $points = $$geotag{Points};
    126     $points or $points = $$geotag{Points} = { };
    127195
    128196    # initialize cuts
    129     my $maxHDOP = $exifTool->Options('GeoMaxHDOP');
    130     my $maxPDOP = $exifTool->Options('GeoMaxPDOP');
    131     my $minSats = $exifTool->Options('GeoMinSats');
     197    my $maxHDOP = $et->Options('GeoMaxHDOP');
     198    my $maxPDOP = $et->Options('GeoMaxPDOP');
     199    my $minSats = $et->Options('GeoMinSats');
    132200    my $isCut = $maxHDOP || $maxPDOP || $minSats;
    133201
     
    136204    my $lastSecs = 0;
    137205    my $fix = { };
     206    my $csvDelim = $et->Options('CSVDelim');
     207    $csvDelim = ',' unless defined $csvDelim;
     208    my (@saveFix, $timeSpan);
    138209    for (;;) {
    139210        $raf->ReadLine($_) or last;
    140211        # determine file format
    141212        if (not $format) {
    142             if (/^<(\?xml|gpx)\s/) { # look for XML or GPX header
     213            if (/^<(\?xml|gpx)[\s>]/) { # look for XML or GPX header
    143214                $format = 'XML';
    144             } elsif (/^\$(PMGNTRK|GP(RMC|GGA|GLL|GSA)),/) {
     215            # check for NMEA sentence
     216            # (must ONLY start with ones that have timestamps! eg. not GSA or PTNTHPR!)
     217            } elsif (/^.*\$([A-Z]{2}(RMC|GGA|GLL|ZDA)|PMGNTRK),/) {
    145218                $format = 'NMEA';
    146219                $nmeaStart = $2 || $1;    # save type of first sentence
     
    149222                $nmeaStart = 'B' ;
    150223                next;
    151             } elsif (/^HFDTE(\d{2})(\d{2})(\d{2})/) {
     224            } elsif (/^HFDTE(?:DATE:)?(\d{2})(\d{2})(\d{2})/) {
    152225                my $year = $3 + ($3 >= 70 ? 1900 : 2000);
    153                 $dateFlarm = Time::Local::timegm(0,0,0,$1,$2-1,$year-1900);
     226                $dateFlarm = Time::Local::timegm(0,0,0,$1,$2-1,$year);
    154227                $nmeaStart = 'B' ;
    155228                $format = 'IGC';
     
    157230            } elsif ($nmeaStart and /^B/) {
    158231                # parse IGC fixes without a date
    159                 $format = 'IGC';               
     232                $format = 'IGC';
     233            } elsif (/^TP,D,/) {
     234                $format = 'Winplus';
     235            } elsif (/^\s*\d+\s+.*\sypr\s*$/ and (@tmp=split) == 12) {
     236                $format = 'Bramor';
     237            } elsif (((/\b(GPS)?Date/i and /\b(GPS)?(Date)?Time/i) or /\bTime\(seconds\)/i) and /\Q$csvDelim/) {
     238                chomp;
     239                @csvHeadings = split /\Q$csvDelim/;
     240                $format = 'CSV';
     241                # convert recognized headings to our parameter names
     242                foreach (@csvHeadings) {
     243                    my $param;
     244                    s/^GPS ?//; # remove leading "GPS" to simplify regex patterns
     245                    if (/^Time ?\(seconds\)$/i) { # DJI
     246                        # DJI CSV log files have a column "Time(seconds)" which is seconds since
     247                        # the start of the flight.  The date/time is obtained from the file name.
     248                        $param = 'runtime';
     249                        if ($trackFile and $trackFile =~ /(\d{4})-(\d{2})-(\d{2})[^\/]+(\d{2})-(\d{2})-(\d{2})[^\/]*$/) {
     250                            $trackTime = Image::ExifTool::TimeLocal($6,$5,$4,$3,$2-1,$1);
     251                            my $utc = PrintFixTime($trackTime);
     252                            my $tzs = Image::ExifTool::TimeZoneString([$6,$5,$4,$3,$2-1,$1-1900],$trackTime);
     253                            $et->VPrint(2, "  DJI start time:  $utc (local timezone is $tzs)\n");
     254                        } else {
     255                            return 'Error getting start time from file name for DJI CSV track file';
     256                        }
     257                    } elsif (/^Date ?Time/i) {  # ExifTool addition
     258                        $param = 'datetime';
     259                    } elsif (/^Date/i) {
     260                        $param = 'date';
     261                    } elsif (/^Time(?! ?\(text\))/i) { # (ignore DJI "Time(text)" column)
     262                        $param = 'time';
     263                    } elsif (/^(Pos)?Lat/i) {
     264                        $param = 'lat';
     265                    } elsif (/^(Pos)?Lon/i) {
     266                        $param = 'lon';
     267                    } elsif (/^(Pos)?Alt/i) {
     268                        $param = 'alt';
     269                    } elsif (/^(Angle)?(Heading|Track)/i) {
     270                        $param = 'track';
     271                    } elsif (/^(Angle)?Pitch/i or /^Camera ?Elevation ?Angle/i) {
     272                        $param = 'pitch';
     273                    } elsif (/^(Angle)?Roll/i) {
     274                        $param = 'roll';
     275                    }
     276                    if ($param) {
     277                        $et->VPrint(2, "CSV column '${_}' is $param\n");
     278                        $_ = $param;
     279                    } else {
     280                        $et->VPrint(2, "CSV column '${_}' ignored\n");
     281                        $_ = '';    # ignore this column
     282                    }
     283                }
     284                next;
    160285            } else {
    161286                # search only first 50 lines of file for a valid fix
     
    170295            my ($arg, $tok, $td);
    171296            s/\s*=\s*(['"])\s*/=$1/g;  # remove unnecessary white space in attributes
     297            # Workaround for KML generated by Google Location History:
     298            # lat/lon/alt are space-separated; we want commas.
     299            s{(\S+)\s+(\S+)\s+(\S+)(</gx:coord>)}{$1,$2,$3$4};
    172300            foreach $arg (split) {
    173                 # parse attributes (ie. GPX 'lat' and 'lon')
     301                # parse attributes (eg. GPX 'lat' and 'lon')
    174302                # (note: ignore namespace prefixes if they exist)
    175303                if ($arg =~ /^(\w+:)?(\w+)=(['"])(.*?)\3/g) {
    176304                    my $tag = $xmlTag{lc $2};
    177                     $$fix{$tag} = $4 if $tag;
     305                    if ($tag) {
     306                        $$fix{$tag} = $4;
     307                        if ($isOrient{$tag}) {
     308                            $$has{orient} = 1;
     309                        } elsif ($tag eq 'alt') {
     310                            # validate altitude
     311                            undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
     312                            $$has{alt} = 1 if $$fix{alt};   # set "has altitude" flag if appropriate
     313                        } elsif ($tag eq 'atemp') {
     314                            $$has{atemp} = 1;
     315                        }
     316                    }
    178317                }
    179318                # loop through XML elements
     
    186325                            # opened: start a new fix
    187326                            $lastFix = $fix = { };
     327                            undef @saveFix;
    188328                            next;
    189329                        } elsif ($fix and $lastFix and %$fix) {
     
    198338                        if ($tag) {
    199339                            if ($tag eq 'coords') {
     340                                # save other fixes if there are more than one
     341                                if (defined $$fix{lon} and defined $$fix{lat} and defined $$fix{alt}) {
     342                                    push @saveFix, [ @$fix{'lon','lat','alt'} ];
     343                                }
    200344                                # read KML "Point" coordinates
    201345                                @$fix{'lon','lat','alt'} = split ',', $1;
    202346                            } else {
    203347                                $$fix{$tag} = $1;
     348                                if ($isOrient{$tag}) {
     349                                    $$has{orient} = 1;
     350                                } elsif ($tag eq 'alt') {
     351                                    # validate altitude
     352                                    undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
     353                                    $$has{alt} = 1 if $$fix{alt};   # set "has altitude" flag if appropriate
     354                                } elsif ($tag eq 'atemp') {
     355                                    $$has{atemp} = 1;
     356                                }
    204357                            }
    205358                        }
     
    209362                    }
    210363                    # validate and store GPS fix
    211                     if (defined $$fix{lat} and defined $$fix{lon} and $$fix{'time'} and
    212                         $$fix{lat} =~ /^[+-]?\d+\.?\d*/ and
    213                         $$fix{lon} =~ /^[+-]?\d+\.?\d*/ and
    214                         $$fix{'time'} =~ /^(\d{4})-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(.*)/)
    215                     {
    216                         $time = Time::Local::timegm($6,$5,$4,$3,$2-1,$1-1900);
    217                         $time += $7 if $7;  # add fractional seconds
    218                         my $tz = $8;
    219                         # adjust for time zone (otherwise assume UTC)
    220                         # - allow timezone of +-HH:MM, +-H:MM, +-HHMM or +-HH since
    221                         #   the spec is unclear about timezone format
    222                         if ($tz =~ /^([-+])(\d+):(\d{2})\b/ or $tz =~ /^([-+])(\d{2})(\d{2})?\b/) {
    223                             $tz = ($2 * 60 + ($3 || 0)) * 60;
    224                             $tz *= -1 if $1 eq '+'; # opposite sign to change back to UTC
    225                             $time += $tz;
     364                    next unless defined $$fix{lat} and defined $$fix{lon} and $$fix{'time'};
     365                    unless ($$fix{lat} =~ /^[+-]?\d+\.?\d*/ and $$fix{lon} =~ /^[+-]?\d+\.?\d*/) {
     366                        $e0 or $et->VPrint(0, "Coordinate format error in $from\n"), $e0 = 1;
     367                        next;
     368                    }
     369                    unless (defined($time = GetTime($$fix{'time'}))) {
     370                        $e1 or $et->VPrint(0, "Timestamp format error in $from\n"), $e1 = 1;
     371                        next;
     372                    }
     373                    $isDate = 1;
     374                    $canCut= 1 if defined $$fix{pdop} or defined $$fix{hdop} or defined $$fix{nsats};
     375                    # generate extra fixes assuming an equally spaced track
     376                    if ($$fix{begin}) {
     377                        my $begin = GetTime($$fix{begin});
     378                        undef $$fix{begin};
     379                        if (defined $begin and $begin < $time) {
     380                            $$fix{span} = $timeSpan = ($timeSpan || 0) + 1;
     381                            my $i;
     382                            # duplicate the fix if there is only one so we will have
     383                            # a fix and the start and end of the TimeSpan
     384                            @saveFix or push @saveFix, [ @$fix{'lon','lat','alt'} ];
     385                            for ($i=0; $i<@saveFix; ++$i) {
     386                                my $t = $begin + ($time - $begin) * ($i / scalar(@saveFix));
     387                                my %f;
     388                                @f{'lon','lat','alt'} = @{$saveFix[$i]};
     389                                $t += 0.001 if not $i and $$points{$t}; # (avoid dupicates)
     390                                $f{span} = $timeSpan;
     391                                $$points{$t} = \%f;
     392                                push @fixTimes, $t;
     393                            }
    226394                        }
    227                         # validate altitude
    228                         undef $$fix{alt} if defined $$fix{alt} and $$fix{alt} !~ /^[+-]?\d+\.?\d*/;
    229                         $isDate = 1;
    230                         $canCut= 1 if defined $$fix{pdop} or defined $$fix{hdop} or defined $$fix{nsats};
    231                         $$points{$time} = $fix;
    232                         push @fixTimes, $time;  # save times of all fixes in order
    233                         $fix = { };
    234                         ++$numPoints;
    235395                    }
     396                    $$points{$time} = $fix;
     397                    push @fixTimes, $time;  # save times of all fixes in order
     398                    $fix = { };
     399                    undef @saveFix;
     400                    ++$numPoints;
    236401                }
    237402            }
     
    240405                /[\s>](\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2}(\.\d+)?)/;
    241406            next;
     407#
     408# Winplus Beacon text file
     409#
     410        } elsif ($format eq 'Winplus') {
     411            # TP,D, 44.933666667, -93.186555556, 10/26/2011, 19:07:28, 0
     412            #       latitude      longitude      date        time
     413            /^TP,D,\s*([-+]?\d+\.\d*),\s*([-+]?\d+\.\d*),\s*(\d+)\/(\d+)\/(\d{4}),\s*(\d+):(\d+):(\d+)/ or next;
     414            $$fix{lat} = $1;
     415            $$fix{lon} = $2;
     416            $time = Time::Local::timegm($8,$7,$6,$4,$3-1,$5);
     417DoneFix:    $isDate = 1;
     418            $$points{$time} = $fix;
     419            push @fixTimes, $time;
     420            $fix = { };
     421            ++$numPoints;
     422            next;
     423#
     424# Bramor gEO log file
     425#
     426        } elsif ($format eq 'Bramor') {
     427            #   1 0015   18.723675   50.672752  149 169.31 22/04/2015 07:06:55 169.31    8.88   28.07 ypr
     428            #   ? index  latitude    longitude  alt track  date       time     dir       pitch  roll
     429            my @parts = split ' ', $_;
     430            next unless @parts == 12 and $parts[11] eq 'ypr';
     431            my @d = split m{/}, $parts[6];  # date (dd/mm/YYYY)
     432            my @t = split m{:}, $parts[7];  # time (HH:MM:SS)
     433            next unless @d == 3 and @t == 3;
     434            @$fix{qw(lat lon alt track dir pitch roll)} = @parts[2,3,4,5,8,9,10];
     435            # (add the seconds afterwards in case some models have decimal seconds)
     436            $time = Time::Local::timegm(0,$t[1],$t[0],$d[0],$d[1]-1,$d[2]) + $t[2];
     437            # set necessary flags for extra available information
     438            @$has{qw(alt track orient)} = (1,1,1);
     439            goto DoneFix;   # save this fix
     440        } elsif ($format eq 'CSV') {
     441            chomp;
     442            my @vals = split /\Q$csvDelim/;
     443#
     444# CSV format output of GPS/IMU POS system
     445#   Date*           - date in DD/MM/YYYY format
     446#   Time*           - time in HH:MM:SS.SSS format
     447#   [Pos]Lat*       - latitude in decimal degrees
     448#   [Pos]Lon*       - longitude in decimal degrees
     449#   [Pos]Alt*       - altitude in m relative to sea level
     450#   [Angle]Heading* - GPSTrack in degrees true
     451#   [Angle]Pitch*   - pitch angle in degrees
     452#   [Angle]Roll*    - roll angle in degrees
     453# (ExifTool enhancements allow for standard tag names or descriptions as the column headings,
     454#  add support for time zones and flexible coordinates, and allow new DateTime and Shift columns)
     455#
     456            my ($param, $date, $secs);
     457            foreach $param (@csvHeadings) {
     458                my $val = shift @vals;
     459                last unless defined $val;
     460                next unless $param;
     461                if ($param eq 'datetime') {
     462                    local $SIG{'__WARN__'} = sub { };
     463                    my $dateTime = $et->InverseDateTime($val);
     464                    if ($dateTime) {
     465                        $date = Image::ExifTool::GetUnixTime($val, 2);
     466                        $secs = 0;
     467                    }
     468                } elsif ($param eq 'date') {
     469                    if ($val =~ m{^(\d{2})/(\d{2})/(\d{4})$}) {
     470                        $date = Time::Local::timegm(0,0,0,$1,$2-1,$3);
     471                    } elsif ($val =~ /(\d{4}).*?(\d{2}).*?(\d{2})/) {
     472                        $date = Time::Local::timegm(0,0,0,$3,$2-1,$1);
     473                    }
     474                } elsif ($param eq 'time') {
     475                    if ($val =~ /^(\d{1,2}):(\d{2}):(\d{2}(\.\d+)?).*?(([-+])(\d{1,2}):?(\d{2}))?/) {
     476                        $secs = (($1 * 60) + $2) * 60 + $3;
     477                        # adjust for time zone if specified
     478                        $secs += ($7 * 60 + $8) * ($6 eq '-' ? 60 : -60) if $5;
     479                    }
     480                } elsif ($param eq 'lat' or $param eq 'lon') {
     481                    $$fix{$param} = Image::ExifTool::GPS::ToDegrees($val, 1);
     482                } elsif ($param eq 'runtime') {
     483                    $date = $trackTime;
     484                    $secs = $val;
     485                } else {
     486                    $$fix{$param} = $val;
     487                }
     488            }
     489            if ($date and defined $secs and defined $$fix{lat} and defined $$fix{lon}) {
     490                $time = $date + $secs;
     491                $$has{alt} = 1 if defined $$fix{alt};
     492                $$has{track} = 1 if defined $$fix{track};
     493                $$has{orient} = 1 if defined $$fix{pitch};
     494                goto DoneFix;
     495            }
     496            next;
    242497        }
    243498        my (%fix, $secs, $date, $nmea);
    244499        if ($format eq 'NMEA') {
    245500            # ignore unrecognized NMEA sentences
    246             next unless /^\$(PMGNTRK|GP(RMC|GGA|GLL|GSA)),/;
    247             $nmea = $2 || $1;
     501            # (first 2 characters: GP=GPS, GL=GLONASS, GA=Gallileo, GN=combined, BD=Beidou)
     502            next unless /^(.*)\$([A-Z]{2}(RMC|GGA|GLL|GSA|ZDA)|PMGNTRK|PTNTHPR),/;
     503            $nmea = $3 || $2;
     504            $_ = substr($_, length($1)) if length($1);
    248505        }
    249506#
    250507# IGC (flarm) (ref 4)
    251508#
    252         if ( $format eq 'IGC' ) {
     509        if ($format eq 'IGC') {
    253510            # B0939564531208N00557021EA007670089100207
    254511            # BHHMMSSDDMMmmmNDDDMMmmmEAaaaaaAAAAAxxyy
     
    262519            # wrap to next day if necessary
    263520            if ($dateFlarm) {
    264                 $dateFlarm += $secPerDay if $secs < $lastSecs;
     521                $dateFlarm += $secPerDay if $secs < $lastSecs - JITTER();
    265522                $date = $dateFlarm;
    266523            }
    267524            $nmea = 'B';
    268 #
    269 # Magellan eXplorist NMEA-like PMGNTRK sentence (optionally contains date)
    270 #
    271         } elsif ($nmea eq 'PMGNTRK') {
    272             # $PMGNTRK,4415.026,N,07631.091,W,00092,M,185031.06,A,,020409*65
    273             # $PMGNTRK,ddmm.mmm,N/S,dddmm.mmm,E/W,alt,F/M,hhmmss.ss,A/V,trkname,DDMMYY*cs
    274             /^\$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;
    275             $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
    276             $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
    277             $fix{alt} = $8 eq 'M' ? $7 : $7 * 12 * 0.0254;
    278             $secs = (($9 * 60) + $10) * 60 + $11;
    279             $secs += $12 if $12;    # add fractional seconds
    280             if (defined $15) {
    281                 # optional date is available in PMGNTRK sentence
    282                 my $year = $15 + ($15 >= 70 ? 1900 : 2000);
    283                 $date = Time::Local::timegm(0,0,0,$13,$14-1,$year-1900);
    284             }
    285525#
    286526# NMEA RMC sentence (contains date)
     
    288528        } elsif ($nmea eq 'RMC') {
    289529            #  $GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,*25
     530            #  $GPRMC,093657.007,,3652.835020,N,01053.104094,E,1.642,,290913,,,A*0F
    290531            #  $GPRMC,hhmmss.sss,A/V,ddmm.mmmm,N/S,ddmmm.mmmm,E/W,spd(knots),dir(deg),DDMMYY,,*cs
    291             /^\$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;
    292             $fix{lat} = ($5 + $6/60) * ($7 eq 'N' ? 1 : -1);
    293             $fix{lon} = ($8 + $9/60) * ($10 eq 'E' ? 1 : -1);
    294             my $year = $13 + ($13 >= 70 ? 1900 : 2000);
     532            /^\$[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;
     533            next if $13 > 31 or $14 > 12 or $15 > 99;   # validate day/month/year
     534            $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
     535            $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
     536            $fix{speed} = $11 if length $11;
     537            $fix{track} = $12 if length $12;
     538            my $year = $15 + ($15 >= 70 ? 1900 : 2000);
    295539            $secs = (($1 * 60) + $2) * 60 + $3;
    296             $secs += $4 if $4;      # add fractional seconds
    297             $date = Time::Local::timegm(0,0,0,$11,$12-1,$year-1900);
     540            $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
    298541#
    299542# NMEA GGA sentence (no date)
     
    301544        } elsif ($nmea eq 'GGA') {
    302545            #  $GPGGA,092204.999,4250.5589,S,14718.5084,E,1,04,24.4,19.7,M,,,,0000*1F
     546            #  $GPGGA,093657.000,3652.835020,N,01053.104094,E,,8,,166.924,M,40.9,M,,*77
    303547            #  $GPGGA,hhmmss.sss,ddmm.mmmm,N/S,dddmm.mmmm,E/W,0=invalid,sats,hdop,alt,M,...
    304             /^\$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;
    305             $fix{lat} = ($5 + $6/60) * ($7 eq 'N' ? 1 : -1);
    306             $fix{lon} = ($8 + $9/60) * ($10 eq 'E' ? 1 : -1);
    307             $fix{nsats} = $11;
    308             $fix{hdop} = $12;
    309             $fix{alt} = $13;
     548            /^\$[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;
     549            $fix{lat} = (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1);
     550            $fix{lon} = (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1);
     551            @fix{qw(nsats hdop alt)} = ($11,$12,$13);
    310552            $secs = (($1 * 60) + $2) * 60 + $3;
    311             $secs += $4 if $4;      # add fractional seconds
    312553            $canCut = 1;
    313554#
     
    317558            #  $GPGLL,4250.5589,S,14718.5084,E,092204.999,A*2D
    318559            #  $GPGLL,ddmm.mmmm,N/S,dddmm.mmmm,E/W,hhmmss.sss,A/V*cs
    319             /^\$GPGLL,(\d+)(\d{2}\.\d+),([NS]),(\d+)(\d{2}\.\d+),([EW]),(\d{2})(\d{2})(\d+)(\.\d+),A/ or next;
    320             $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
    321             $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
     560            /^\$[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;
     561            $fix{lat} = (($1 || 0) + $2/60) * ($3 eq 'N' ? 1 : -1);
     562            $fix{lon} = (($4 || 0) + $5/60) * ($6 eq 'E' ? 1 : -1);
    322563            $secs = (($7 * 60) + $8) * 60 + $9;
    323             $secs += $10 if $10;    # add fractional seconds
    324564#
    325565# NMEA GSA sentence (satellite status, no date)
     
    327567        } elsif ($nmea eq 'GSA') {
    328568            # $GPGSA,A,3,04,05,,,,,,,,,,,pdop,hdop,vdop*HH
    329             /^\$GPGSA,[AM],([23]),((?:\d*,){11}(?:\d*)),(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?\*/ or next;
     569            /^\$[A-Z]{2}GSA,[AM],([23]),((?:\d*,){11}(?:\d*)),(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?,(\d+\.?\d*|\.\d+)?\*/ or next;
    330570            @fix{qw(fixtype sats pdop hdop vdop)} = ($1.'d',$2,$3,$4,$5);
    331571            # count the number of acquired satellites
     
    333573            $fix{nsats} = scalar @a;
    334574            $canCut = 1;
     575#
     576# NMEA ZDA sentence (date/time, contains date)
     577#
     578        } elsif ($nmea eq 'ZDA') {
     579            #  $GPZDA,093655.000,29,09,2013,,*58
     580            #  $GPZDA,hhmmss.ss,DD,MM,YYYY,tzh,tzm (hhmmss in UTC)
     581            /^\$[A-Z]{2}ZDA,(\d{2})(\d{2})(\d{2}(\.\d*)?),(\d+),(\d+),(\d+)/ or next;
     582            $secs = (($1 * 60) + $2) * 60 + $3;
     583            $date = Time::Local::timegm(0,0,0,$5,$6-1,$7);
     584#
     585# Magellan eXplorist PMGNTRK (Proprietary MaGellaN TRacK) sentence (optional date)
     586#
     587        } elsif ($nmea eq 'PMGNTRK') {
     588            # $PMGNTRK,4415.026,N,07631.091,W,00092,M,185031.06,A,,020409*65
     589            # $PMGNTRK,ddmm.mmm,N/S,dddmm.mmm,E/W,alt,F/M,hhmmss.ss,A/V,trkname,DDMMYY*cs
     590            /^\$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;
     591            $fix{lat} = ($1 + $2/60) * ($3 eq 'N' ? 1 : -1);
     592            $fix{lon} = ($4 + $5/60) * ($6 eq 'E' ? 1 : -1);
     593            $fix{alt} = $8 eq 'M' ? $7 : $7 * 12 * 0.0254;
     594            $secs = (($9 * 60) + $10) * 60 + $11;
     595            if (defined $15) {
     596                next if $13 > 31 or $14 > 12 or $15 > 99;   # validate day/month/year
     597                # optional date is available in PMGNTRK sentence
     598                my $year = $15 + ($15 >= 70 ? 1900 : 2000);
     599                $date = Time::Local::timegm(0,0,0,$13,$14-1,$year);
     600            }
     601#
     602# Honeywell HMR3000 PTNTHPR (Heading Pitch Roll) sentence (no date)
     603# (ref http://www.gpsarea.com/uploadfile/download/introduce/hmr3000_manual.pdf)
     604#
     605        } elsif ($nmea eq 'PTNTHPR') {
     606            # $PTNTHPR,85.9,N,-0.9,N,0.8,N*HH
     607            # $PTNTHPR,heading,heading status,pitch,pitch status,roll,roll status,*cs
     608            # status: L=low alarm, M=low warning, N=normal, O=high warning
     609            #         P=high alarm, C=tuning analog circuit
     610            # (ignore this information on any alarm status)
     611            /^\$PTNTHPR,(-?[\d.]+),[MNO],(-?[\d.]+),[MNO],(-?[\d.]+),[MNO]/ or next;
     612            @fix{qw(dir pitch roll)} = ($1,$2,$3);
    335613
    336614        } else {
    337615            next;   # this shouldn't happen
    338616        }
     617        # remember the NMEA formats we successfully read
     618        $nmea{$nmea} = 1;
    339619        # use last date if necessary (and appropriate)
    340620        if (defined $secs and not defined $date and defined $lastDate) {
    341621            # wrap to next day if necessary
    342             if ($secs < $lastSecs) {
     622            if ($secs < $lastSecs - JITTER()) {
    343623                $lastSecs -= $secPerDay;
    344624                $lastDate += $secPerDay;
     
    360640        }
    361641#
    362 # Add NMEA fix to our lookup
     642# Add NMEA/IGC fix to our lookup
    363643# (this is much more complicated than it needs to be because
    364644#  the stupid NMEA format provides no end-of-fix indication)
     
    426706    if ($noDate and not $$geotag{NoDate}) {
    427707        if ($isDate) {
    428             $exifTool->Warn('Fixes are date-less -- will use time-only interpolation');
     708            $et->Warn('Fixes are date-less -- will use time-only interpolation');
    429709        } else {
    430             $exifTool->Warn('Some fixes are date-less -- may use time-only interpolation');
     710            $et->Warn('Some fixes are date-less -- may use time-only interpolation');
    431711        }
    432712        $$geotag{NoDate} = 1;
     
    466746        last;
    467747    }
    468     my $verbose = $exifTool->Options('Verbose');
     748    my $verbose = $et->Options('Verbose');
    469749    if ($verbose) {
    470         my $out = $exifTool->Options('TextOut');
    471         print $out "Loaded $numPoints points from GPS track log $from\n";
     750        my $out = $et->Options('TextOut');
     751        $format or $format = 'unknown';
     752        print $out "Loaded $numPoints points from $format-format GPS track log $from\n";
    472753        print $out "Ignored $cutPDOP points due to GeoMaxPDOP cut\n" if $cutPDOP;
    473754        print $out "Ignored $cutHDOP points due to GeoMaxHDOP cut\n" if $cutHDOP;
    474755        print $out "Ignored $cutSats points due to GeoMinSats cut\n" if $cutSats;
    475756        if ($numPoints and $verbose > 1) {
    476             print $out '  GPS track start: ' . Image::ExifTool::ConvertUnixTime($fixTimes[0]) . " UTC\n";
     757            my @lbl = ('start:', 'end:  ');
     758            # (fixes may be in reverse order in GPX files)
     759            @lbl = reverse @lbl if $fixTimes[0] > $fixTimes[-1];
     760            print $out "  GPS track $lbl[0] " . PrintFixTime($fixTimes[0]) . "\n";
    477761            if ($verbose > 3) {
    478                 foreach $time (@fixTimes) {
    479                     $fix = $$points{$time} or next;
    480                     print $out '    ',Image::ExifTool::ConvertUnixTime($time),' UTC -';
    481                     foreach (sort keys %$fix) {
    482                         print $out " $_=$$fix{$_}" unless $_ eq 'time';
    483                     }
    484                     print $out "\n";
    485                 }
    486             }
    487             print $out '  GPS track end:   ' . Image::ExifTool::ConvertUnixTime($fixTimes[-1]) . " UTC\n";
     762                print $out PrintFix($points, $_) foreach @fixTimes;
     763            }
     764            print $out "  GPS track $lbl[1] " . PrintFixTime($fixTimes[-1]) . "\n";
    488765        }
    489766    }
     
    491768        # reset timestamp list to force it to be regenerated
    492769        delete $$geotag{Times};
     770        # set flags for available information
     771        $$has{alt} = 1 if $nmea{GGA} or $nmea{PMGNTRK} or $nmea{B}; # alt
     772        $$has{track} = 1 if $nmea{RMC};                             # track, speed
     773        $$has{orient} = 1 if $nmea{PTNTHPR};                        # dir, pitch, roll
    493774        return $geotag;     # success!
    494775    }
    495776    return "No track points found in GPS $from";
     777}
     778
     779
     780#------------------------------------------------------------------------------
     781# Get floating point UTC time
     782# Inputs: 0) XML time string
     783# Returns: floating point time or undef on error
     784sub GetTime($)
     785{
     786    my $timeStr = shift;
     787    $timeStr =~ /^(\d{4})-(\d+)-(\d+)T(\d+):(\d+):(\d+)(\.\d+)?(.*)/ or return undef;
     788    my $time = Time::Local::timegm($6,$5,$4,$3,$2-1,$1);
     789    $time += $7 if $7;  # add fractional seconds
     790    my $tz = $8;
     791    # adjust for time zone (otherwise assume UTC)
     792    # - allow timezone of +-HH:MM, +-H:MM, +-HHMM or +-HH since
     793    #   the spec is unclear about timezone format
     794    if ($tz =~ /^([-+])(\d+):(\d{2})\b/ or $tz =~ /^([-+])(\d{2})(\d{2})?\b/) {
     795        $tz = ($2 * 60 + ($3 || 0)) * 60;
     796        $tz *= -1 if $1 eq '+'; # opposite sign to change back to UTC
     797        $time += $tz;
     798    }
     799    return $time;
    496800}
    497801
     
    502806sub ApplySyncCorr($$)
    503807{
    504     my ($exifTool, $time) = @_;
    505     my $sync = $exifTool->GetNewValues('Geosync');
     808    my ($et, $time) = @_;
     809    my $sync = $et->GetNewValue('Geosync');
    506810    if (ref $sync eq 'HASH') {
    507811        my $syncTimes = $$sync{Times};
     
    511815            while ($i1 > $i0 + 1) {
    512816                my $pt = int(($i0 + $i1) / 2);
    513                 if ($time < $$syncTimes[$pt]) {
    514                     $i1 = $pt;
    515                 } else {
    516                     $i0 = $pt;
    517                 }
     817                ($time < $$syncTimes[$pt] ? $i1 : $i0) = $pt;
    518818            }
    519819            my ($t0, $t1) = ($$syncTimes[$i0], $$syncTimes[$i1]);
     
    530830    }
    531831    return $sync;
     832}
     833
     834#------------------------------------------------------------------------------
     835# Scan outwards for a fix containing the requested parameter
     836# Inputs: 0) name of fix parameter, 1) reference to list of fix times,
     837#         2) reference to fix points hash, 3) index of starting time,
     838#         4) direction to scan (-1 or +1), 5) maximum time difference
     839# Returns: 0) time for fix containing requested information (or undef)
     840#          1) the corresponding fix, 2) the value of the requested fix parameter
     841sub ScanOutwards($$$$$$)
     842{
     843    my ($key, $times, $points, $i, $dir, $maxSecs) = @_;
     844    my $t0 = $$times[$i];
     845    for (;;) {
     846        $i += $dir;
     847        last if $i < 0 or $i >= scalar @$times;
     848        my $t = $$times[$i];
     849        last if abs($t - $t0) > $maxSecs;   # don't look too far
     850        my $p = $$points{$t};
     851        my $v = $$p{$key};
     852        return($t,$p,$v) if defined $v;
     853    }
     854    return();
     855}
     856
     857#------------------------------------------------------------------------------
     858# Find nearest fix containing the specified parameter
     859# Inputs: 0) ExifTool ref, 1) name of fix parameter, 2) reference to list of fix times,
     860#         3) reference to fix points hash, 4) index of starting time,
     861#         5) direction to scan (-1, +1 or undef), 6) maximum time difference
     862# Returns: reference to fix hash or undef
     863sub FindFix($$$$$$$)
     864{
     865    my ($et, $key, $times, $points, $i, $dir, $maxSecs) = @_;
     866    my ($t,$p);
     867    if ($dir) {
     868        ($t,$p) = ScanOutwards($key, $times, $points, $i, $dir, $maxSecs);
     869    } else {
     870        my ($t1, $p1) = ScanOutwards($key, $times, $points, $i, -1, $maxSecs);
     871        my ($t2, $p2) = ScanOutwards($key, $times, $points, $i, 1, $maxSecs);
     872        if (defined $t1) {
     873            if (defined $t2) {
     874                # both surrounding points are valid, so take the closest one
     875                ($t, $p) = ($t - $t1 < $t2 - $t) ? ($t1, $p1) : ($t2, $p2);
     876            } else {
     877                ($t, $p) = ($t1, $p1);
     878            }
     879        } elsif (defined $t2) {
     880            ($t, $p) = ($t2, $p2);
     881        }
     882    }
     883    if (defined $p and $$et{OPTIONS}{Verbose} > 2) {
     884        $et->VPrint(2, "  Taking $key from fix:\n", PrintFix($points, $t))
     885    }
     886    return $p;
    532887}
    533888
     
    541896{
    542897    local $_;
    543     my ($exifTool, $val, $writeGroup) = @_;
    544     my $geotag = $exifTool->GetNewValues('Geotag');
    545     my ($fix, $time, $fsec, $noDate, $secondTry);
     898    my ($et, $val, $writeGroup) = @_;
     899    my $geotag = $et->GetNewValue('Geotag');
     900    my $verbose = $et->Options('Verbose');
     901    my ($fix, $time, $fsec, $noDate, $secondTry, $iExt, $iDir);
    546902
    547903    # remove date if none of our fixes had date information
     
    549905
    550906    # maximum time (sec) from nearest GPS fix when position is still considered valid
    551     my $geoMaxIntSecs = $exifTool->Options('GeoMaxIntSecs');
    552     my $geoMaxExtSecs = $exifTool->Options('GeoMaxExtSecs');
     907    my $geoMaxIntSecs = $et->Options('GeoMaxIntSecs');
     908    my $geoMaxExtSecs = $et->Options('GeoMaxExtSecs');
     909
    553910    # use 30 minutes for a default
    554911    defined $geoMaxIntSecs or $geoMaxIntSecs = 1800;
    555912    defined $geoMaxExtSecs or $geoMaxExtSecs = 1800;
    556913
     914    my $times = $$geotag{Times};
    557915    my $points = $$geotag{Points};
     916    my $has = $$geotag{Has};
    558917    my $err = '';
    559918    # loop to try date/time value first, then time-only value
     
    563922            last;
    564923        }
    565         my $times = $$geotag{Times};
    566924        unless ($times) {
    567925            # generate sorted timestamp list for binary search
     
    569927            $times = $$geotag{Times} = \@times;
    570928        }
    571         unless ($times and @$times) {
     929        unless ($times and @$times or $$geotag{DateTimeOnly}) {
    572930            $err = 'GPS track is empty';
    573931            last;
    574932        }
    575         unless (eval 'require Time::Local') {
     933        unless (eval { require Time::Local }) {
    576934            $err = 'Geotag feature requires Time::Local installed';
    577935            last;
     
    593951        }
    594952        if ($tz) {
    595             $time = Time::Local::timegm($sec,$min,$hr,$day,$mon-1,$year-1900);
     953            $time = Time::Local::timegm($sec,$min,$hr,$day,$mon-1,$year);
    596954            # use timezone from date/time value
    597955            if ($tz ne 'Z') {
     
    601959        } else {
    602960            # assume local timezone
    603             $time = Image::ExifTool::TimeLocal($sec,$min,$hr,$day,$mon-1,$year-1900);
     961            $time = Image::ExifTool::TimeLocal($sec,$min,$hr,$day,$mon-1,$year);
    604962        }
    605963        # add fractional seconds
     
    607965
    608966        # bring UTC time back to Jan. 1 if no date is given
    609         $time %= $secPerDay if $noDate;
     967        # (don't use '%' operator here because it drops fractional seconds)
     968        $time -= int($time / $secPerDay) * $secPerDay if $noDate;
    610969
    611970        # apply time synchronization if available
    612         my $sync = ApplySyncCorr($exifTool, $time);
     971        my $sync = ApplySyncCorr($et, $time);
    613972
    614973        # save fractional seconds string
    615974        $fsec = ($time =~ /(\.\d+)$/) ? $1 : '';
    616975
    617         if ($exifTool->Options('Verbose') > 1 and not $secondTry) {
    618             my $out = $exifTool->Options('TextOut');
    619             my $str = "$fsec UTC";
     976        if ($et->Options('Verbose') > 1 and not $secondTry) {
     977            my $out = $et->Options('TextOut');
     978            my $str = '';
    620979            $str .= sprintf(" (incl. Geosync offset of %+.3f sec)", $sync) if defined $sync;
    621             print $out '  Geotime value:   ' . Image::ExifTool::ConvertUnixTime(int $time) . "$str\n";
    622         }
     980            unless ($tz) {
     981                my $tzs = Image::ExifTool::TimeZoneString([$sec,$min,$hr,$day,$mon-1,$year-1900],$time);
     982                $str .= " (local timezone is $tzs)";
     983            }
     984            print $out '  Geotime value:   ' . PrintFixTime($time) . "$str\n";
     985        }
     986        if (not $times or not @$times) {
     987            $fix = { }; # dummy fix to geotag date/time only
    623988        # interpolate GPS track at $time
    624         if ($time < $$times[0]) {
     989        } elsif ($time < $$times[0]) {
    625990            if ($time < $$times[0] - $geoMaxExtSecs) {
    626991                $err or $err = 'Time is too far before track';
     992                $et->VPrint(2, '  Track start:     ', PrintFixTime($$times[0]), "\n") if $verbose > 2;
     993                $fix = { } if $$geotag{DateTimeOnly};
    627994            } else {
    628995                $fix = $$points{$$times[0]};
     996                $iExt = 0;  $iDir = 1;
     997                $et->VPrint(2, "  Taking pos from fix:\n",
     998                    PrintFix($points, $$times[0])) if $verbose > 2;
    629999            }
    6301000        } elsif ($time > $$times[-1]) {
    6311001            if ($time > $$times[-1] + $geoMaxExtSecs) {
    6321002                $err or $err = 'Time is too far beyond track';
     1003                $et->VPrint(2, '  Track end:       ', PrintFixTime($$times[-1]), "\n") if $verbose > 2;
     1004                $fix = { } if $$geotag{DateTimeOnly};
    6331005            } else {
    6341006                $fix = $$points{$$times[-1]};
     1007                $iExt = $#$times;  $iDir = -1;
     1008                $et->VPrint(2, "  Taking pos from fix:\n",
     1009                    PrintFix($points, $$times[-1])) if $verbose > 2;
    6351010            }
    6361011        } else {
     
    6391014            while ($i1 > $i0 + 1) {
    6401015                my $pt = int(($i0 + $i1) / 2);
    641                 if ($time < $$times[$pt]) {
    642                     $i1 = $pt;
    643                 } else {
    644                     $i0 = $pt;
    645                 }
     1016                ($time < $$times[$pt] ? $i1 : $i0) = $pt;
    6461017            }
    6471018            # do linear interpolation for position
     
    6501021            my $p1 = $$points{$t1};
    6511022            # check to see if we are extrapolating before the first entry in a track
    652             my $maxSecs = $$p1{first} ? $geoMaxExtSecs : $geoMaxIntSecs;
     1023            my $maxSecs = ($$p1{first} and $geoMaxIntSecs) ? $geoMaxExtSecs : $geoMaxIntSecs;
    6531024            # don't interpolate if fixes are too far apart
    654             if ($t1 - $t0 > $maxSecs) {
     1025            # (but always interpolate fixes inside the same TimeSpan)
     1026            if ($t1 - $t0 > $maxSecs and (not $$p1{span} or not $$points{$t0}{span} or
     1027                $$p1{span} != $$points{$t0}{span}))
     1028            {
    6551029                # treat as an extrapolation -- use nearest fix if close enough
    656                 my $tn = ($time - $t0 < $t1 - $time) ? $t0 : $t1;
     1030                my $tn;
     1031                if ($time - $t0 < $t1 - $time) {
     1032                    $tn = $t0;
     1033                    $iExt = $i0;
     1034                } else {
     1035                    $tn = $t1;
     1036                    $iExt = $i1;
     1037                }
    6571038                if (abs($time - $tn) > $geoMaxExtSecs) {
    6581039                    $err or $err = 'Time is too far from nearest GPS fix';
     1040                    $et->VPrint(2, '  Nearest fix:     ', PrintFixTime($tn), "\n") if $verbose > 2;
     1041                    $fix = { } if $$geotag{DateTimeOnly};
    6591042                } else {
    6601043                    $fix = $$points{$tn};
     1044                    $et->VPrint(2, "  Taking pos from fix:\n",
     1045                        PrintFix($points, $tn)) if $verbose > 2;
    6611046                }
    6621047            } else {
    663                 my $f = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
     1048                my $f0 = $t1 == $t0 ? 0 : ($time - $t0) / ($t1 - $t0);
    6641049                my $p0 = $$points{$t0};
     1050                $et->VPrint(2, "  Interpolating between fixes (f=$f0):\n",
     1051                    PrintFix($points, $t0, $t1)) if $verbose > 2;
    6651052                $fix = { };
    666                 # loop through latitude, longitude, and altitude if available
    667                 foreach (qw(lat lon alt)) {
    668                     next unless defined $$p0{$_} and defined $$p1{$_};
    669                     $$fix{$_} = $$p1{$_} * $f + $$p0{$_} * (1 - $f);
     1053                # loop through available fix information categories
     1054                # (pos, track, alt, orient)
     1055                my ($category, $key);
     1056Category:       foreach $category (qw{pos track alt orient atemp}) {
     1057                    next unless $$has{$category};
     1058                    my ($f, $p0b, $p1b, $f0b);
     1059                    # loop through specific fix information keys
     1060                    # (lat, lon, alt, track, speed, dir, pitch, roll)
     1061                    foreach $key (@{$fixInfoKeys{$category}}) {
     1062                        my $v0 = $$p0{$key};
     1063                        my $v1 = $$p1{$key};
     1064                        if (defined $v0 and defined $v1) {
     1065                            $f = $f0;
     1066                        } elsif (defined $f0b) {
     1067                            $v0 = $$p0b{$key};
     1068                            $v1 = $$p1b{$key};
     1069                            next unless defined $v0 and defined $v1;
     1070                            $f = $f0b;
     1071                        } else {
     1072                            # scan outwards looking for fixes with the required information
     1073                            # (NOTE: SHOULD EVENTUALLY DO THIS FOR EXTRAPOLATION TOO!)
     1074                            my ($t0b, $t1b);
     1075                            if (defined $v0) {
     1076                                $t0b = $t0;  $p0b = $p0;
     1077                            } else {
     1078                                ($t0b,$p0b,$v0) = ScanOutwards($key,$times,$points,$i0,-1,$maxSecs);
     1079                                next Category unless defined $t0b;
     1080                            }
     1081                            if (defined $v1) {
     1082                                $t1b = $t1;  $p1b = $p1;
     1083                            } else {
     1084                                ($t1b,$p1b,$v1) = ScanOutwards($key,$times,$points,$i1,1,$maxSecs);
     1085                                next Category unless defined $t1b;
     1086                            }
     1087                            # re-calculate the interpolation factor
     1088                            $f = $f0b = $t1b == $t0b ? 0 : ($time - $t0b) / ($t1b - $t0b);
     1089                            $et->VPrint(2, "  Interpolating $category between fixes (f=$f):\n",
     1090                                PrintFix($points, $t0b, $t1b)) if $verbose > 2;
     1091                        }
     1092                        # must interpolate cyclical values differently
     1093                        if ($cyclical{$key} and abs($v1 - $v0) > 180) {
     1094                            # the acute angle spans the discontinuity, so add
     1095                            # 360 degrees to the smaller angle before interpolating
     1096                            $v0 < $v1 ? $v0 += 360 : $v1 += 360;
     1097                            $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
     1098                            # longitude and roll ranges are -180 to 180, others are 0 to 360
     1099                            my $max = ($key eq 'lon' or $key eq 'roll') ? 180 : 360;
     1100                            $$fix{$key} -= 360 if $$fix{$key} >= $max;
     1101                        } else {
     1102                            # simple linear interpolation
     1103                            $$fix{$key} = $v1 * $f + $v0 * (1 - $f);
     1104                        }
     1105                    }
    6701106                }
    6711107            }
     
    6901126            $gpsAlt = abs $$fix{alt};
    6911127            $gpsAltRef = ($$fix{alt} < 0 ? 1 : 0);
     1128        } elsif ($$has{alt} and defined $iExt) {
     1129            my $tFix = FindFix($et,'alt',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
     1130            if ($tFix) {
     1131                $gpsAlt = abs $$tFix{alt};
     1132                $gpsAltRef = ($$tFix{alt} < 0 ? 1 : 0);
     1133            }
    6921134        }
    6931135        # set new GPS tag values (EXIF, or XMP if write group is 'xmp')
     
    7001142        }
    7011143        # (capture error messages by calling SetNewValue in list context)
    702         @r = $exifTool->SetNewValue(GPSLatitude => $$fix{lat}, %opts);
    703         @r = $exifTool->SetNewValue(GPSLongitude => $$fix{lon}, %opts);
    704         @r = $exifTool->SetNewValue(GPSAltitude => $gpsAlt, %opts);
    705         @r = $exifTool->SetNewValue(GPSAltitudeRef => $gpsAltRef, %opts);
     1144        @r = $et->SetNewValue(GPSLatitude => $$fix{lat}, %opts);
     1145        @r = $et->SetNewValue(GPSLongitude => $$fix{lon}, %opts);
     1146        @r = $et->SetNewValue(GPSAltitude => $gpsAlt, %opts);
     1147        @r = $et->SetNewValue(GPSAltitudeRef => $gpsAltRef, %opts);
     1148        if ($$has{track}) {
     1149            my $tFix = $fix;
     1150            if (not defined $$fix{track} and defined $iExt) {
     1151                my $p = FindFix($et,'track',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
     1152                $tFix = $p if $p;
     1153            }
     1154            @r = $et->SetNewValue(GPSTrack => $$tFix{track}, %opts);
     1155            @r = $et->SetNewValue(GPSTrackRef => (defined $$tFix{track} ? 'T' : undef), %opts);
     1156            my ($spd, $ref);
     1157            if (defined($spd = $$tFix{speed})) {
     1158                $ref = $$et{OPTIONS}{GeoSpeedRef};
     1159                if ($ref and defined $speedConv{$ref}) {
     1160                    $ref = $speedConv{$ref} if $speedConv{$speedConv{$ref}};
     1161                    $spd *= $speedConv{$ref};
     1162                } else {
     1163                    $ref = 'N';     # knots by default
     1164                }
     1165            }
     1166            @r = $et->SetNewValue(GPSSpeed => $spd, %opts);
     1167            @r = $et->SetNewValue(GPSSpeedRef => $ref, %opts);
     1168        }
     1169        if ($$has{orient}) {
     1170            my $tFix = $fix;
     1171            if (not defined $$fix{dir} and defined $iExt) {
     1172                my $p = FindFix($et,'dir',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
     1173                $tFix = $p if $p;
     1174            }
     1175            @r = $et->SetNewValue(GPSImgDirection => $$tFix{dir}, %opts);
     1176            @r = $et->SetNewValue(GPSImgDirectionRef => (defined $$tFix{dir} ? 'T' : undef), %opts);
     1177            @r = $et->SetNewValue(CameraElevationAngle => $$tFix{pitch}, %opts);
     1178            # Note: GPSPitch and GPSRoll are non-standard, and must be user-defined
     1179            @r = $et->SetNewValue(GPSPitch => $$tFix{pitch}, %opts);
     1180            @r = $et->SetNewValue(GPSRoll => $$tFix{roll}, %opts);
     1181        }
     1182        if ($$has{atemp}) {
     1183            my $tFix = $fix;
     1184            if (not defined $$fix{atemp} and defined $iExt) {
     1185                # (not all fixes have atemp, so try interpolating specifically for this)
     1186                my $p = FindFix($et,'atemp',$times,$points,$iExt,$iDir,$geoMaxExtSecs);
     1187                $tFix = $p if $p;
     1188            }
     1189            @r = $et->SetNewValue(AmbientTemperature => $$tFix{atemp}, %opts);
     1190        }
    7061191        unless ($xmp) {
    707             @r = $exifTool->SetNewValue(GPSLatitudeRef => ($$fix{lat} > 0 ? 'N' : 'S'), %opts);
    708             @r = $exifTool->SetNewValue(GPSLongitudeRef => ($$fix{lon} > 0 ? 'E' : 'W'), %opts);
    709             @r = $exifTool->SetNewValue(GPSDateStamp => $gpsDate, %opts);
    710             @r = $exifTool->SetNewValue(GPSTimeStamp => $gpsTime, %opts);
     1192            my ($latRef, $lonRef);
     1193            $latRef = ($$fix{lat} > 0 ? 'N' : 'S') if defined $$fix{lat};
     1194            $lonRef = ($$fix{lon} > 0 ? 'E' : 'W') if defined $$fix{lon};
     1195            @r = $et->SetNewValue(GPSLatitudeRef => $latRef, %opts);
     1196            @r = $et->SetNewValue(GPSLongitudeRef => $lonRef, %opts);
     1197            @r = $et->SetNewValue(GPSDateStamp => $gpsDate, %opts);
     1198            @r = $et->SetNewValue(GPSTimeStamp => $gpsTime, %opts);
    7111199            # set options to edit XMP:GPSDateTime only if it already exists
    7121200            $opts{EditOnly} = 1;
     
    7141202        }
    7151203        unless ($exif) {
    716             @r = $exifTool->SetNewValue(GPSDateTime => "$gpsDate $gpsTime", %opts);
     1204            @r = $et->SetNewValue(GPSDateTime => "$gpsDate $gpsTime", %opts);
    7171205        }
    7181206    } else {
    719         my %opts;
     1207        my %opts = ( IgnorePermanent => 1 );
    7201208        $opts{Replace} = 2 if defined $val; # remove existing new values
    7211209        $opts{Group} = $writeGroup if $writeGroup;
     1210
    7221211        # reset any GPS values we might have already set
    7231212        foreach (qw(GPSLatitude GPSLatitudeRef GPSLongitude GPSLongitudeRef
    724                     GPSAltitude GPSAltitudeRef GPSDateStamp GPSTimeStamp GPSDateTime))
     1213                    GPSAltitude GPSAltitudeRef GPSDateStamp GPSTimeStamp GPSDateTime
     1214                    GPSTrack GPSTrackRef GPSSpeed GPSSpeedRef GPSImgDirection
     1215                    GPSImgDirectionRef GPSPitch GPSRoll CameraElevationAngle
     1216                    AmbientTemperature))
    7251217        {
    726             my @r = $exifTool->SetNewValue($_, undef, %opts);
     1218            my @r = $et->SetNewValue($_, undef, %opts);
    7271219        }
    7281220    }
     
    7351227#         1) time difference string ("[+-]DD MM:HH:SS.ss"), geosync'd file name,
    7361228#            "GPSTIME@IMAGETIME", or "GPSTIME@FILENAME"
    737 # Returns: geosync hash
     1229# Returns: geosync hash:
     1230#           Offset = Offset in seconds for latest synchronization (GPS - image time)
     1231#           Points = hash of all sync offsets keyed by image times in seconds
     1232#           Times = sorted list of image synchronization times (keys in Points hash)
    7381233# Notes: calling this routine with more than one geosync'd file causes time drift
    7391234#        correction to be implemented
    7401235sub ConvertGeosync($$)
    7411236{
    742     my ($exifTool, $val) = @_;
    743     my $sync = $exifTool->GetNewValues('Geosync') || { };
     1237    my ($et, $val) = @_;
     1238    my $sync = $et->GetNewValue('Geosync') || { };
    7441239    my ($syncFile, $gpsTime, $imgTime);
    7451240
    7461241    if ($val =~ /(.*?)\@(.*)/) {
    7471242        $gpsTime = $1;
    748         if (-f $2) {
    749             $syncFile = $2;
    750         } else {
    751             $imgTime = $2;
    752         }
     1243        (-f $2 ? $syncFile : $imgTime) = $2;
    7531244    # (take care because "-f '1:30'" crashes ActivePerl 5.10)
    7541245    } elsif ($val !~ /^\d/ or $val !~ /:/) {
     
    7641255                                 'GPSDateTime', 'GPSTimeStamp');
    7651256            $$info{Error} and warn("$$info{Err}\n"), return undef;
    766             $gpsTime or $gpsTime = $$info{GPSDateTime} || $$info{GPSTimeStamp};
     1257            unless ($gpsTime) {
     1258                $gpsTime = $$info{GPSDateTime} || $$info{GPSTimeStamp};
     1259                $gpsTime .= 'Z' if $gpsTime and not $$info{GPSDateTime};
     1260            }
     1261            $gpsTime or warn("No GPSTimeStamp in '$syncFile\n"), return undef;
    7671262            my $tag;
    7681263            foreach $tag (@timeTags) {
    7691264                if ($$info{$tag}) {
    7701265                    $imgTime = $$info{$tag};
    771                     $exifTool->VPrint(2, "Geosyncing with $tag from '$syncFile'\n");
     1266                    $et->VPrint(2, "Geosyncing with $tag from '${syncFile}'\n");
    7721267                    last;
    7731268                }
    7741269            }
    775             $gpsTime or warn("No GPSTimeStamp in '$syncFile\n"), return undef;
    776             $imgTime or warn("No image timestamp in '$syncFile'\n"), return undef;
     1270            $imgTime or warn("No image timestamp in '${syncFile}'\n"), return undef;
    7771271        }
    7781272        # add date to date-less timestamps
     
    7991293        # calculate Unix seconds since the epoch
    8001294        my $imgSecs = Image::ExifTool::GetUnixTime($imgDateTime, 1);
    801         defined $imgSecs or warn("Invalid image time '$imgTime'\n"), return undef;
     1295        defined $imgSecs or warn("Invalid image time '${imgTime}'\n"), return undef;
    8021296        my $gpsSecs = Image::ExifTool::GetUnixTime($gpsDateTime, 1);
    803         defined $gpsSecs or warn("Invalid GPS time '$gpsTime'\n"), return undef;
     1297        defined $gpsSecs or warn("Invalid GPS time '${gpsTime}'\n"), return undef;
    8041298        # add fractional seconds
    8051299        $gpsSecs += $1 if $gpsTime =~ /(\.\d+)/;
     
    8231317            $$sync{Points}{$imgSecs} = $$sync{Offset};
    8241318            # print verbose output
    825             if ($exifTool->Options('Verbose') > 1) {
     1319            if ($et->Options('Verbose') > 1) {
    8261320                # print GPS and image timestamps in UTC
    827                 my $gps = Image::ExifTool::ConvertUnixTime($gpsSecs);
    828                 my $img = Image::ExifTool::ConvertUnixTime($imgSecs);
    829                 $gps .= $1 if $gpsTime =~ /(\.\d+)/;
    830                 $img .= $1 if $imgTime =~ /(\.\d+)/;
    831                 $exifTool->VPrint(1, "Added Geosync point:\n",
    832                                      "  GPS time stamp:  $gps UTC\n",
    833                                      "  Image date/time: $img UTC\n");
     1321                $et->VPrint(1, "Added Geosync point:\n",
     1322                               '  GPS time stamp:  ', PrintFixTime($gpsSecs), "\n",
     1323                               '  Image date/time: ', PrintFixTime($imgSecs), "\n");
    8341324            }
    8351325            # save sorted list of image sync times if we have more than one
     
    8571347
    8581348#------------------------------------------------------------------------------
     1349# Print fix time
     1350# Inputs: 0) time since the epoch
     1351# Returns: UTC time string with fractional seconds
     1352sub PrintFixTime($)
     1353{
     1354    my $time = $_[0] + 0.0005;  # round off to nearest ms
     1355    my $fsec = int(($time - int($time)) * 1000);
     1356    return sprintf('%s.%.3d UTC', Image::ExifTool::ConvertUnixTime($time), $fsec);
     1357}
     1358
     1359#------------------------------------------------------------------------------
     1360# Print fix information
     1361# Inputs: 0) lookup for all fix points, 1-n) list of fix times
     1362# Returns: fix string (including leading indent and trailing newline)
     1363sub PrintFix($@)
     1364{
     1365    local $_;
     1366    my $points = shift;
     1367    my $str = '';
     1368    while (@_) {
     1369        my $time = shift;
     1370        $str .= '    ' . PrintFixTime($time) . ' -';
     1371        my $fix = $$points{$time};
     1372        if ($fix) {
     1373            foreach (sort keys %$fix) {
     1374                $str .= " $_=$$fix{$_}" unless $_ eq 'time' or not defined $$fix{$_};
     1375            }
     1376        }
     1377        $str .= "\n";
     1378    }
     1379    return $str;
     1380}
     1381
     1382#------------------------------------------------------------------------------
    85913831;  # end
    8601384
     
    8731397This module loads GPS track logs, interpolates to determine position based
    8741398on time, and sets new GPS values for geotagging images.  Currently supported
    875 formats are GPX, NMEA RMC/GGA/GLL, KML, IGC, Garmin XML and TCX, and
    876 Magellan PMGNTRK.
     1399formats are GPX, NMEA RMC/GGA/GLL, KML, IGC, Garmin XML and TCX, Magellan
     1400PMGNTRK, Honeywell PTNTHPR, Winplus Beacon text, IMU CSV, DJI CSV, and
     1401Bramor gEO log files.
    8771402
    8781403Methods in this module should not be called directly.  Instead, the Geotag
     
    8811406in the tag name documentation).
    8821407
     1408=head1 NOTES
     1409
     1410To take advantage of attitude information in the PTNTHPR NMEA sentence, the
     1411user-defined tag GPSRoll, must be active.
     1412
    8831413=head1 AUTHOR
    8841414
    885 Copyright 2003-2011, Phil Harvey (phil at owl.phy.queensu.ca)
     1415Copyright 2003-2021, Phil Harvey (philharvey66 at gmail.com)
    8861416
    8871417This library is free software; you can redistribute it and/or modify it
     
    9121442
    9131443=cut
    914 
Note: See TracChangeset for help on using the changeset viewer.