#!/usr/bin/perl -w
#
# Copyright (C) 2005-2008,2010,2012,2016,2020,2021,2022,2023,2024,2025 Slaven Rezic. All rights reserved.
# This program is free software; you can redistribute it and/or
# modify it under the same terms as Perl itself.
#
# WWW:  https://github.com/eserte/bbbike
#

use strict;
our $VERSION = 1.43;

use Cwd qw(realpath);
use File::Spec;
use File::Basename;
use JSON::PP qw(decode_json);
use Getopt::Long;
use Image::ExifTool qw(ImageInfo);
eval 'use Digest::MD5 qw(md5_hex);';
use POSIX qw(strftime);
use Time::Local qw(timelocal);
use DB_File;
use FindBin;

use lib ("$FindBin::RealBin/..", "$FindBin::RealBin/../lib");
use GPS::GpsmanData;
use Karte::Standard;
use Karte::Polar;
use BBBikeUtil qw(s2hm is_in_path);

sub parse_clock_adjust_file ($);
sub _isodate_to_epoch ($);

my $gps_data_dir = "$FindBin::RealBin/../misc/gps_data";
my @additional_tracks_dirs;
my $no_gps_data_dir;
my $thumbnail_dir = "/tmp/thumbnails";
my $clock_gps;
my $clock_camera;
my $clock_adjust_file;
my $anchor;
my $thumbnail_size = 50;
my $converter = "best";
my $recreate_thumbnails;
my $date_index_file;
my $date_db;
my $do_update;
my $do_interpolate;
my $allow_overwrite_gpspos;
my $prefer_gps = 'photo';
my $v;
my $debug;
my $relative_paths;
my $out_file;
my $max_delta = 180;
my $for_person;
my $gps_track_sync_grace_time = 14; # in days; means that GPS track syncs may happen that number of days after making the photos
my $mode = 'external-geocoding';
my @valid_modes = qw(external-geocoding manipulate-copy manipulate-inplace);

my $lax_iso_date_qr = qr{^(\d{4})\D*(\d{2})\D*(\d{2})T(\d{2})\D(\d{2})\D(\d{2})};
my $clock_qr        = qr{^\d{1,2}:\d{2}(:\d{2})?};

my $current_info = 'BEGIN';
{
    no warnings 'signal'; # INFO is usually only available on BSD systems
    $SIG{INFO} = sub {
	print STDERR $current_info, "\n";
	require Carp; Carp::carp('Currently');
    };
}

sub usage (;$) {
    my $msg = shift;
    warn "$msg\n" if $msg;
    die <<EOF;
usage: $0
          [-gpsdatadir directory | -nogpsdatadir] [-thumbnaildir directory]
	  [-clockadjustfile file | -clockgps HH:MM:SS -clockcamera HH:MM:SS]
	  [-anchor nw|n|ne|e|se|s|sw] [-thumbnailsize pixels]
          [-converter Image::GD::Thumbnail|ImageMagick|ImageMagick+exiftool|best|fast]
	  [-recreatethumbnails] [-rel|-relativepaths] [-o file]
	  [-addtracksdir dir ...]
	  [-person ...]
	  [-interpolate] [-allow-overwrite-gpspos] [-prefer-gps photo|track]
          [-mode external-geocoding|manipulate-copy|manipulate-inplace]
          [-v] [-debug] image ...
EOF
}

GetOptions("gpsdatadir=s" => \$gps_data_dir,
	   "nogpsdatadir" => \$no_gps_data_dir,
	   "dateindex=s" => \$date_index_file,
	   "thumbnaildir=s" => \$thumbnail_dir,
	   'addtracksdir=s@' => \@additional_tracks_dirs,
	   "clockgps=s" => \$clock_gps,
	   "clockcamera=s" => \$clock_camera,
	   "clockadjustfile=s" => \$clock_adjust_file,
	   "anchor=s" => \$anchor,
	   "thumbnailsize=i" => \$thumbnail_size,
	   "converter=s" => \$converter,
	   "recreatethumbnails" => \$recreate_thumbnails,
	   "rel|relativepaths!" => \$relative_paths,
	   "update" => \$do_update,
	   "interpolate!" => \$do_interpolate,
	   "allow-overwrite-gpspos!" => \$allow_overwrite_gpspos,
	   "prefer-gps=s" => \$prefer_gps,
	   "person=s" => \$for_person,
	   "o=s" => \$out_file,
	   "v!" => \$v,
	   "debug" => \$debug,
	   "mode=s" => sub {
	       $mode = $_[1];
	       my $rx = "(" . join("|", map { quotemeta } @valid_modes) . ")";
	       $rx = qr{$rx};
	       if ($mode !~ $rx) {
		   usage "Invalid mode '$mode'. Valid modes are: @valid_modes\n";
	       }
	   },
	  ) or usage;

undef $gps_data_dir if $no_gps_data_dir;

if ($prefer_gps !~ m{^(photo|track)$}) {
    usage "Invalid value for -prefer-gps (should be 'photo' or 'track', got '$prefer_gps')";
}

my @images = @ARGV;
if (!@images) {
    usage "Please specify at least one image";
}

my @cam_delta_mapping;
my @ignore_mapping;
if ($clock_adjust_file) {
    undef $clock_gps;
    undef $clock_camera;
    parse_clock_adjust_file $clock_adjust_file;
}

my $cam_delta = 0;
if ($clock_gps || $clock_camera) {
    for my $check ([\$clock_gps,    "-clockgps"],
		   [\$clock_camera, "-clockcamera"],
		  ) {
	my($varref, $opt) = @$check;
	if ($$varref !~ $clock_qr) {
	    die "$opt must be in format HH:MM or HH:MM:SS";
	}
    }
    $cam_delta = get_cam_delta($clock_gps, $clock_camera);
}

my @converter;
if ($converter eq 'best') {
    push @converter, 'ImageMagick', 'ImageMagick+exiftool', 'Image::GD::Thumbnail';
} elsif ($converter eq 'fast') {
    push @converter, 'ImageMagick+exiftool', 'Image::GD::Thumbnail', 'ImageMagick';
} elsif ($converter eq 'veryfast') {
    push @converter, 'Image::GD::Thumbnail', 'ImageMagick+exiftool', 'ImageMagick';
} else {
    @converter = $converter;
    undef $converter;
}

for my $try_converter (@converter) {
    if ($try_converter eq 'Image::GD::Thumbnail') {
	if (eval { require GD; require Image::GD::Thumbnail; 1 }) {
	    $converter = $try_converter;
	    last;
	}
    } elsif ($try_converter eq 'ImageMagick+exiftool') {
	if (is_in_path("convert") && $^O ne 'MSWin32') {
	    $converter = $try_converter;
	    last;
	}
    } elsif ($try_converter eq 'ImageMagick') {
	if (is_in_path("convert") && $^O ne 'MSWin32') {
	    $converter = $try_converter;
	    last;
	}
    } else {
	die "Unknown converter <$try_converter>";
    }
}
if (!$converter) {
    die "Can't use any converter from @converter";
}
if ($v) {
    print STDERR "The converter '$converter' will be used.\n";
}

if ($date_index_file) {
    $date_db = tie my %db, 'DB_File', $date_index_file, O_RDONLY, 0644, $DB_BTREE
	or die "Can't tie $date_index_file: $!";
    
}

sub _get_utc_iso_date {
    my($y,$m,$d,$H,$M,$S, $offset) = @_;
    require DateTime;
    my $dt = DateTime->new(year => $y, month => $m, day => $d,
			   hour => $H, minute => $M, second => $S,
			   time_zone => 'UTC');
    $dt->set_time_zone($offset);
    $dt->strftime("%Y:%m:%d %H:%M:%S");
}

sub get_image_location {
    my($file, %opts) = @_;
    my $do_interpolate = delete $opts{interpolate};
    my $prefer_gps = delete $opts{prefer_gps};
    die "Unhandled options: " . join(" ", %opts) if %opts;

    my($y,$m,$d,$H,$M,$S);

    my($camera_make, $camera_model, $camera_make_model);

    if (defined &ImageInfo) {
	my $exif = ImageInfo($file);
	if ($exif) {

	    $camera_make = $exif->{Make};
	    $camera_model = $exif->{Model};
	    if ($camera_make && $camera_model) {
		$camera_make_model = "$camera_make $camera_model";
	    }

	    # Prefer GPS position recorded in EXIF:
	    my($lat_deg, $lat_min, $lat_sec, $lat_ref, $lon_deg, $lon_min, $lon_sec, $lon_ref);
	    my $polar_qr = qr{(\d+)\s+deg\s+(\d+)\'\s+([\d\.]+)\"};
	FIND_GPS_POS: {
		last FIND_GPS_POS if $prefer_gps eq 'track';

		if ($exif->{GPSPosition}) {
		    if (($lat_deg, $lat_min, $lat_sec, $lat_ref, $lon_deg, $lon_min, $lon_sec, $lon_ref) =
			$exif->{GPSPosition} =~ m{
						  ^
						  $polar_qr\s+([NS]),\s*
						  $polar_qr\s+([EW])
					      }x) {
			$lat_deg *= -1 if $lat_ref =~ m{s}i;
			$lon_deg *= -1 if $lon_ref =~ m{w}i;
			# Hack needed for some Nokia N9 images
			for ($lat_sec, $lon_sec) {
			    if ($_ == 60) {
				warn "Hack: fix obviously wrong sec=60 in <$file>...\n";
				$_ = 59.9999;
			    }
			}
		    } else {
			warn "Cannot parse GPSPosition <$exif->{GPSPosition}>";
			last FIND_GPS_POS;
		    }
		} elsif ($exif->{GPSLatitude} && $exif->{GPSLongitude} &&
		    $exif->{GPSLatitudeRef} && $exif->{GPSLongitudeRef}) {
		    if (!(($lat_deg, $lat_min, $lat_sec) = $exif->{GPSLatitude} =~ $polar_qr)) {
			warn "Cannot parse GPSLatitude <$exif->{GPSLatitude}>";
			last FIND_GPS_POS;
		    }
		    if (!(($lon_deg, $lon_min, $lon_sec) = $exif->{GPSLongitude} =~ $polar_qr)) {
			warn "Cannot parse GPSLongitude <$exif->{GPSLongitude}>";
			last FIND_GPS_POS;
		    }
		    $lat_deg *= -1 if $exif->{GPSLatitudeRef} =~ m{^s}i;
		    $lon_deg *= -1 if $exif->{GPSLongitudeRef} =~ m{^w}i;
		} else {
		    last FIND_GPS_POS;
		}
		my $lat = Karte::Polar::dms2ddd($lat_deg, $lat_min, $lat_sec);
		my $lon = Karte::Polar::dms2ddd($lon_deg, $lon_min, $lon_sec);
		my $iso_date = $exif->{DateTimeOriginal} || $exif->{CreateDate};
		if (!$iso_date && $exif->{GPSDateTime} && $camera_make_model ne 'Nokia N95') { # GPSDateTime is nonsense in the N95
		    eval {
			if (my($y,$m,$d) = $exif->{GPSDateStamp} =~ m{^(\d{4}):(\d{2}):(\d{2})$}) {
			    if (my($H,$M,$S) = $exif->{GPSDateTime} =~ m{^(\d{2}):(\d{2}):(\d{2})}) {
				$iso_date = _get_utc_iso_date($y,$m,$d,$H,$M,$S,$exif->{OffsetTime});
			    } else {
				die "Cannot parse GPSDateTime '$exif->{GPSDateTime}'";
			    }
			} else {
			    die "Cannot parse GPSDateStamp '$exif->{GPSDateStamp}'";
			}
		    };
		    if (!$iso_date) {
			warn "Cannot get date from image ($@)\n";
			last FIND_GPS_POS;
		    }
		}
		$iso_date =~ s{^(\d+):(\d+):(\d+)}{$1-$2-$3};
		$iso_date =~ s{ }{T}; # -> iso date sep
		# XXX missing: Altitude
		my $ret = { Longitude => $lon,
			    Latitude  => $lat,
			    Delta    => 0,
			    Waypoint => undef, # ? XXX
			    Date     => $iso_date,
			    Used     => "exact",
			    Source   => "image",
			  };
		return $ret;
	    }

	    my $date;
	    {
		# Special handling for images taken with Mapillary
		# App: unlike the normal iPhone camera App,
		# DateTimeOriginal is in UTC, not local time.
		# Therefore check the ImageDescription json structure
		# for fields MAPGpsTime and MAPLocalTimeZone to get
		# the real local time.
		my $image_struct_desc = eval {
		    decode_json $exif->{ImageDescription}
		};
		if ($image_struct_desc && $image_struct_desc->{MAPGpsTime} && $image_struct_desc->{MAPGpsTime} =~ m{^(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})}) {
		    my($y,$m,$d,$H,$M,$S) = ($1,$2,$3,$4,$5,$6);
		    if ($image_struct_desc->{MAPLocalTimeZone} =~ /\boffset\s+(-?\d+)\b/) {
			my $offset_hm = sprintf "%+03d:%02d", $1 / 3600, abs($1) % 3600;
			$date = _get_utc_iso_date($y,$m,$d,$H,$M,$S,$offset_hm);
		    } else {
			warn "WARN: $file: Got MAPGpsTime, but cannot parse MAPLocalTimeZone (" . ($image_struct_desc->{MAPLocalTimeZone} || "<undef>") . "), fallback to normal DateTimeOriginal etc. parsing\n";
		    }
		}
	    }
	    if (!$date) {
		$date = $exif->{DateTimeOriginal} || $exif->{CreateDate};
	    }
	    if (defined $date && $date =~ /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/) {
		($y,$m,$d,$H,$M,$S) = ($1,$2,$3,$4,$5,$6);
	    }
	}
    }

    return if !defined $gps_data_dir;

    if (!defined $y) {
	# Nokia 7650 hack
	my $info = ImageInfo($file);
	if (my $error = $info->{Error}) {
	    if ($file =~ m{(?:^|/)\.(?:xvpics|thumbs)/}) {
		# expected error, be quiet
	    } else {
		warn "Can't parse image info from $file: $error\n";
	    }
	    return;
	} elsif (($info->{MIMEType}||'') !~ m{^image/}) { # Image::ExifTool 11.85 does not set the Error field on text files, so check for the MIMEType, too
	    warn "$file does not look like an image (MIMEType: " . ($info->{MIMEType}||"<unset>") . ")";
	    return;
	}

	if (defined $info->{Comment}) {
	    my(@lines) = split /\n/, $info->{Comment};
	    my $date = $lines[2];
	    my $time = $lines[3];

	    if (!defined $date || $date !~ m{^(\d+)[/.-](\d+)[/.-](\d{4})$}) {
		local $^W = 0;
		warn "Can't parse date <$date> in file <$file>, cannot geolocate, skipping...\n";
		return;
	    }
	    ($y,$m,$d) = ($3,$2,$1);

	    if (!defined $time || $time !~ m{^(\d+)[:\.](\d+)[:\.](\d+)$}) {
		local $^W = 0;
		warn "Can't parse time <$time> in file <$file>, cannot geolocate, skipping...\n";
		return;
	    }
	    ($H,$M,$S) = ($1,$2,$3);
	}
    }

    if (!defined $y) {
	warn "Cannot get date from image <$file>, skipping...\n"
	    if $v;
	return;
    }

    my $epoch = timelocal($S,$M,$H,$d,$m-1,$y-1900);
    return if check_ignore($epoch, $camera_make_model);
    my $this_cam_delta = get_matching_cam_delta($epoch, $camera_make_model) || $cam_delta;
    $epoch -= $this_cam_delta;
    my @l = localtime $epoch;
    ($S,$M,$H,$d,$m,$y) = @l;
    $y+=1900;
    $m++;

    my $iso_date = sprintf "%04d-%02d-%02dT%02d:%02d:%02d", $y,$m,$d,$H,$M,$S;
    my $iso_date2 = sprintf "%04d-%02d-%02dT%02d%02d%02d", $y,$m,$d,$H,$M,$S;

    if ($date_db) {
	my($key, $val) = ($iso_date2, "");
	my $st = $date_db->seq($key, $val, R_CURSOR);
	my $used;
	if ($st == 0) {
	    if (my($fy,$fm,$fd,$fH,$fM,$fS) = $key =~ m{^(\d+)-(\d+)-(\d+)T(\d{2})(\d{2})(\d{2})}) {
		my $got_epoch = timelocal($fS,$fM,$fH,$fd,$fm-1,$fy-1900);
		my $delta = abs($got_epoch - $epoch);
		my(@fields) = split / /, $val;
		$used = "next";

		# got previous
		$st = $date_db->seq($key, $val, R_PREV);
		if ($st == 0) {
		    if (my($py,$pm,$pd,$pH,$pM,$pS) = $key =~ m{^(\d+)-(\d+)-(\d+)T(\d{2})(\d{2})(\d{2})}) {
			my $got_prev_epoch = timelocal($pS,$pM,$pH,$pd,$pm-1,$py-1900);
			my $prev_delta = abs($got_prev_epoch - $epoch);
			if ($prev_delta < $delta) {
			    # prefer previous
			    (@fields) = split / /, $val;
			    $delta = $prev_delta;
			    $used = "prev";
			}
		    }
		}

		if ($delta <= $max_delta) {
		    # XXX missing: Altitude
		    my $ret = { Longitude => $fields[1],
				Latitude  => $fields[2],
				Delta    => $delta,
				Waypoint => undef, # !
				Date     => $iso_date,
				Used     => $used,
				Source   => "trk",
			      };
		    return $ret;
		}
	    }
	}
	warn "Can't find $iso_date2 in DB, use fallback...\n";
    }

    my $isodate1 = sprintf("%04d%02d%02d", $y,$m,$d);
    my $isodate2 = sprintf("%04d-%02d-%02d", $y,$m,$d);

    my @try_basenames = ("$isodate1.trk", "$isodate2.trk");

    my @filenames;
    if (@additional_tracks_dirs) {
	for my $additional_tracks_dir (@additional_tracks_dirs) {
	    push @filenames, map {
		$additional_tracks_dir . "/" . $_
	    } @try_basenames;
	}
    }
    push @filenames, map {
	(
	 $gps_data_dir . "/" . $_,
	 $gps_data_dir . "/generated/" . $_, # This is the directory where generated tracks (e.g. originally from GPX) live
	)
    } @try_basenames;

    my $filename;
    for my $check_filename (@filenames) {
	warn "Try $check_filename for date $iso_date...\n"
	    if $v;
	if (-e $check_filename) {
	    $filename = $check_filename;
	    last;
	}
    }

    if (!$filename) {
	for my $check_filename (
				(@additional_tracks_dirs
				 ? map {
				     (
				      glob("$_/*$isodate1*.trk"),
				      glob("$_/*$isodate2*.trk"),
				     )
				 } @additional_tracks_dirs
				 : ()
				),
				glob("$gps_data_dir/*$isodate1*.trk"),
				glob("$gps_data_dir/*$isodate1*.track*"),
				glob("$gps_data_dir/*$isodate2*.trk"),
				glob("$gps_data_dir/*$isodate2*.track*"),
			       ) {
	    warn "Try $check_filename (via globbing)...\n"
		if $v;
	    if (-e $check_filename) { # should never fail
		$filename = $check_filename;
		last;
	    }
	}
    }

    if ($filename) {
	my $gps = GPS::GpsmanMultiData->new;
	$gps->load($filename);
	my $got_wpt;
	my $last_wpt;
	my $delta;
    CHUNKS: {
	    my $current_gps_person;
	    for my $chunk (@{ $gps->Chunks }) {
		if ($for_person) {
		    my $new_gps_person = $chunk->TrackAttrs && $chunk->TrackAttrs->{'srt:person'};
		    if ($new_gps_person) {
			$current_gps_person = $new_gps_person;
		    }
		    next CHUNKS if $current_gps_person && $current_gps_person ne $for_person;
		}
		for my $wpt (@{ $chunk->Track }) {
		    my $wpt_epoch = $wpt->Comment_to_unixtime($chunk);
		    if ($wpt_epoch > $epoch) {
			if ($last_wpt && $do_interpolate) {
			    $got_wpt = interpolate($last_wpt, $chunk,
						   $wpt,      $chunk,
						   $epoch);
			} elsif ($last_wpt &&
			    abs($last_wpt->Comment_to_unixtime($chunk) - $epoch) <
			    abs($wpt_epoch - $epoch)) {
			    $got_wpt = $last_wpt;
			} else {
			    $got_wpt = $wpt;
			}
			last CHUNKS;
		    }
		    $last_wpt = $wpt;
		}
	    }
	}
	if (!$got_wpt) {
	    $got_wpt = ($gps->flat_track)[-1];
	}
	$delta = abs($got_wpt->Comment_to_unixtime($gps->Chunks->[0]) - $epoch);
	if ($got_wpt) {
	    return { Latitude  => $got_wpt->Latitude,
		     Longitude => $got_wpt->Longitude,
		     Altitude  => $got_wpt->Altitude,
		     Delta     => $delta,
		     Waypoint  => $got_wpt,
		     Date      => $iso_date,
		     Source   => "trk",
		   };
	}
    }
    undef;
}

if  ($mode =~ m{^manipulate-(copy|inplace)$}) {
    manipulate(mode => $1);
    exit;
}

mkdir $thumbnail_dir if !-d $thumbnail_dir;
if (!-d $thumbnail_dir) {
    die "Cannot create thumbnail directory <$thumbnail_dir>: $!";
}

my %seen_image;
if ($do_update) {
    if (!defined $out_file) {
	die "-update switch only works together with -o\n";
    }
    if (!-e $out_file) {
	# assume first-time operation, touch the output file
	open my $fh, '>', $out_file
	    or die "Can't touch $out_file: $!";
    }
    require Strassen::Core;
    my $s = Strassen->new_stream($out_file);
    my $image_qr = qr{^Image: "([^"]+)"};
    $s->read_stream
	(sub {
	     my($r) = @_;
	     if (UNIVERSAL::isa($r, 'HASH')) {
		 (my $line = $r->{line}) =~ s{^\#\s+}{};
		 $line =~ s{\n$}{};
		 if ($line =~ $image_qr) {
		     $seen_image{$1} = "# $line";
		 }
	     } elsif ($r->[Strassen::NAME()] =~ $image_qr) {
		 $seen_image{$1} = Strassen::arr2line2($r);
	     } else {
		 warn "Cannot parse line '$r->[Strassen::NAME()]', skipping...\n";
	     }
	 },
	 PreserveComments => 1,
	);
}

my $ofh;
if (defined $out_file) {
    $out_file = realpath $out_file;
    open $ofh, ">", "$out_file.$$"
	or die "Cannot write to $out_file.$$: $!";
    select $ofh;
}

for my $file (@images) {
    if (-d $file) {
	require File::Find;
	$File::Find::name = $File::Find::name if 0; # cease -w
	$File::Find::prune = $File::Find::prune if 0; # cease -w
	my @files;
	File::Find::find(sub {
			     if ($_ eq '.xvpics' || $_ eq '.thumbs' || $_ eq 'iPod Photo Cache') {
				 $File::Find::prune = 1;
				 return;
			     }
			     if (-f $_ && -r $_) {
				 push @files, $File::Find::name;
			     }
			 }, $file);
	for my $file (@files) {
	    handle_file($file);
	}
    } else {
	handle_file($file);
    }
}
$current_info = "END";

if ($ofh) {
    close $ofh
	or die "While closing filehandle: $!";
    rename "$out_file.$$", $out_file
	or die "Can't rename $out_file.$$ to $out_file: $!";
    select STDOUT;
}

sub interpolate {
    my($wpt1, $wpt1_container, $wpt2, $wpt2_container, $camera_epoch) = @_;
    my $epoch1 = $wpt1->Comment_to_unixtime($wpt1_container);
    my $epoch2 = $wpt2->Comment_to_unixtime($wpt2_container);
    my $fraction = ($camera_epoch-$epoch1)/($epoch2-$epoch1);
    my($lon1, $lat1, $alt1) = ($wpt1->Longitude, $wpt1->Latitude, $wpt1->Altitude);
    my($lon2, $lat2, $alt2) = ($wpt2->Longitude, $wpt2->Latitude, $wpt2->Altitude);
    my $lon = ($lon2-$lon1)*$fraction + $lon1;
    my $lat = ($lat2-$lat1)*$fraction + $lat1;
    my $alt = ($alt2-$alt1)*$fraction + $alt1;

    my $got_wpt = GPS::Gpsman::Waypoint->new;
    $got_wpt->Latitude($lat);
    $got_wpt->Longitude($lon);
    $got_wpt->Altitude($alt);
    $got_wpt->unixtime_to_Comment($camera_epoch, $wpt1_container);
    $got_wpt;
}

sub handle_file {
    my $file = shift;
    print STDERR "Handling $file...\n" if $v;
    $current_info = "Handling $file";
    my $file_in_bbd;
    if ($relative_paths) {
	if ($out_file) {
	    $file_in_bbd = File::Spec->abs2rel(File::Spec->rel2abs(realpath($file)), dirname($out_file));
	} else {
	    $file_in_bbd = $file;
	}
    } else {
	$file_in_bbd = File::Spec->rel2abs($file);
    }
    if ($seen_image{$file_in_bbd}) {
	print $seen_image{$file_in_bbd}, "\n";
    } else {
	my $ret = get_image_location($file, prefer_gps => $prefer_gps);
	if (defined $ret) {
	    my($lat, $long, $delta, $date) = @{$ret}{qw(Latitude Longitude Delta Date)};
	    my($x, $y) = $Karte::Standard::obj->trim_accuracy
		($Karte::Polar::obj->map2standard($long, $lat));
	    my $thumb_file = create_thumbnail($file);
	    if (defined $thumb_file) {
		if ($relative_paths) {
		    $thumb_file = File::Spec->abs2rel(realpath($thumb_file),
						      (defined $out_file ? dirname($out_file) : ())
						     );
		}
		my $delta_hm = s2hm($delta);
		print qq{Image: "$file_in_bbd" ($date) (delta=$delta_hm) ($lat/$long)\tIMG:$thumb_file};
		if ($anchor) {
		    print qq{|ANCHOR:$anchor};
		}
		print qq{ $x,$y\n};
	    }
	} else {
	    if (-M $file < $gps_track_sync_grace_time) {
		# make sure this text does not match /Image: "..."/
		print qq{# $file is too fresh, maybe it can be geocoded later\n};
	    } else {
		print qq{# Image: "$file_in_bbd" (not geocodable)\n};
	    }
	}
    }
}

sub create_thumbnail {
    my $file = shift;
    $current_info = "Create thumbnail for $file";

    # Save your thumbnail
    my($dest, $ext);
    if (defined &md5_hex) {
	($ext) = $file =~ /(\.[^\.]+)$/;
	$ext = "" if !defined $ext;
	$dest = $thumbnail_dir . "/" . md5_hex($file);
    } else {
	my $base = basename($file);
	if ($base =~ /(.+)(\.[^\.]+)$/) {
	    ($base, $ext) = ($1, $2);
	} else {
	    $ext = "";
	}
	$dest = $thumbnail_dir . "/" . $base;
    }
    if ($ext =~ m{\.(nef|xcf)$}i) { # XXX maybe add more formats, or just accept gif/png/jpe?g
	$ext = '.jpg';
    }
    $dest .= $ext;

    return $dest if (-r $dest && !$recreate_thumbnails);

    warn "Create thumbnail for $file...\n" if $v;

    if ($converter eq 'Image::GD::Thumbnail') {
	$dest = create_thumbnail_Image_GD_Thumbnail($file, $dest);
    } elsif ($converter eq 'ImageMagick') {
	$dest = create_thumbnail_ImageMagick($file, $dest);
    } elsif ($converter eq 'ImageMagick+exiftool') {
	$dest = create_thumbnail_ImageMagick_exiftool($file, $dest);
    } else {
	die "Unknown converter $converter";
    }

    if (defined $dest) {
	maybe_correct_orientation($file, $dest);
    }

    $dest;
}

sub create_thumbnail_Image_GD_Thumbnail {
    my($file, $dest) = @_;
    my ($thumb,$x,$y);
    eval {
	open my $IN, $file or die "Could not open $file: $!";
	binmode $IN;
	my $srcImage = GD::Image->new($IN);
	close $IN;
	
	($thumb,$x,$y) = Image::GD::Thumbnail::create($srcImage,$thumbnail_size);
    };
    if ($@) {
	warn $@;
	return undef;
    } else {
	open my $OUT, ">", "$dest.$$" or die "Could not save $dest: $!";
	binmode $OUT;
	print $OUT $thumb->jpeg;
	close $OUT;
	rename "$dest.$$", $dest
	    or die "Cannot rename $dest.$$ to $dest: $!";
	return $dest;
    }
}

sub create_thumbnail_ImageMagick_exiftool {
    my($file, $dest) = @_;
    my $exiftool = Image::ExifTool->new;
    $exiftool->ExtractInfo($file);
    my $tmpfile;
    for my $tag (qw(ThumbnailImage PreviewImage)) {
	warn "Try tag $tag in $file...\n" if $debug;
	my $val = $exiftool->GetValue($tag);
	if ($val) {
	    warn "Got $tag, write now to temporary file...\n" if $debug;
	    require File::Temp;
	    (my($tmpfh),$tmpfile) = File::Temp::tempfile(SUFFIX => "_geocode_images.jpg", UNLINK => 1);
	    print $tmpfh $$val;
	    close $tmpfh
		or die "Cannot write to $tmpfile: $!";
	    last;
	}
    }
    my $smallfile;
    if (defined $tmpfile) {
	$smallfile = $tmpfile;
    } else {
	warn "No Thumbnail/PreviewImage found in $file, convert from original file...\n" if $debug;
	$smallfile = $file;
    }
    $dest = create_thumbnail_ImageMagick($smallfile, $dest);
    if (defined $tmpfile) {
	unlink $tmpfile; # delete as soon as possible
    }
    $dest;
}

sub create_thumbnail_ImageMagick {
    my($file, $dest) = @_;
    # -strip: remove EXIF from thumbnail, otherwise it's huge!
    # -flatten: for multi-layer images (e.g. xcf): create only one average image
    my @cmd = ("convert", "-quality", 70, "-flatten", "-strip", "-resize", $thumbnail_size ."x". $thumbnail_size, $file, $dest);
    warn "@cmd\n" if $v;
    system @cmd;
    if ($? != 0 || -z $dest) {
	warn "@cmd failed: $?";
	return undef;
    } else {
	return $dest;
    }
}

{
my $no_jpegtran_warned;
sub maybe_correct_orientation {
    my($orig, $thumbnail) = @_;
    if (!is_in_path('jpegtran')) { # XXX should fallback to Image::JpegTran if available
	if (!$no_jpegtran_warned++) {
	    warn "jpegtran not available, cannot correct orientation...\n";
	}
	return;
    }
    my $info = ImageInfo($orig, ["Orientation"],{PrintConv => 1});
    if ($info && defined $info->{Orientation}) {
	my $rotated_file;
	my $orientation = $info->{Orientation};
	if ($orientation eq 'Horizontal (normal)') {
	    # do nothing
	} elsif ($orientation eq 'Rotate 90 CW') {
	    $rotated_file = _run_jpegtran(['-rotate', 90], $thumbnail);
	} elsif ($orientation eq 'Rotate 180') {
	    $rotated_file = _run_jpegtran(['-rotate', 180], $thumbnail);
	} elsif ($orientation eq 'Rotate 270 CW') {
	    $rotated_file = _run_jpegtran(['-rotate', 270], $thumbnail);
	} else {
	    # ignore
	}
	if (defined $rotated_file) {
	    rename $rotated_file, $thumbnail
		or die "Cannot rename $rotated_file to $thumbnail: $!";
	}
    }
}
}

sub _run_jpegtran {
    my($args, $file) = @_;
    my $dest = "$file.$$";
    my @cmd = ("jpegtran", @$args, '-outfile', $dest, $file);
    warn "Running @cmd...\n" if $v;
    system @cmd;
    if ($? != 0) {
	die "Running '@cmd' failed";
    }
    if (-z $dest) {
	die "Destination file $dest is empty";
    }
    $dest;
}

sub get_cam_delta {
    my($clock_gps, $clock_camera) = @_;

    my($gps_H, $gps_M, $gps_S) = split /:/, $clock_gps;
    my($cam_H, $cam_M, $cam_S) = split /:/, $clock_camera;
    my $gps_seconds = $gps_H*60*60 + $gps_M*60 + ($gps_S||0);
    my $cam_seconds = $cam_H*60*60 + $cam_M*60 + ($cam_S||0);
    $cam_delta = $cam_seconds - $gps_seconds;
    
    $cam_delta;
}

sub get_matching_cam_delta {
    my($epoch, $camera_make_model) = @_;
    for my $def (@cam_delta_mapping) {
	my($from_epoch, $to_epoch, $this_cam_delta, %add_opt) = @$def;
	if ($camera_make_model && $add_opt{valid_for_camera} && $camera_make_model ne $add_opt{valid_for_camera}) {
	    next;
	}
	if ($from_epoch <= $epoch && $epoch <= $to_epoch) {
	    warn "Get cam delta=$this_cam_delta for $epoch...\n" if $debug;
	    return $this_cam_delta;
	}
    }
    if ($debug) {
	no warnings;
	warn "Found NO matching cam delta for $epoch and camera=$camera_make_model...\n";
    }
    undef;
}

sub check_ignore {
    my($epoch, $camera_make_model) = @_;
    for my $def (@ignore_mapping) {
	my($from_epoch, $to_epoch, $reason, %add_opt) = @$def;
	if ($camera_make_model && $add_opt{valid_for_camera} && $camera_make_model ne $add_opt{valid_for_camera}) {
	    next;
	}
	if ($from_epoch <= $epoch && $epoch <= $to_epoch) {
	    if ($debug) {
		no warnings;
		warn "Ignore $epoch because of '$reason' ($from_epoch..$to_epoch, camera=$camera_make_model\n";
	    }
	    return 1;
	}
    }
    0;
}

# Side effects:
# - populate @cam_delta_mapping (adding to existing entries)
sub parse_clock_adjust_file ($) {
    my $clock_adjust_file = shift;
    open my $fh, $clock_adjust_file
	or die "Can't open file $clock_adjust_file: $!";
    my %add_opts;
    while(<$fh>) {
	chomp;
	if (m{^\s*#:\s*camera:?\s*(.*)}) {
	    $add_opts{valid_for_camera} = $1;
	    if ($add_opts{valid_for_camera} =~ m{^\s*$}) {
		delete $add_opts{valid_for_camera};
	    }
	    next;
	}
	next if m{^\s*\#};
	next if m{^\s*$};
	my(@fields) = split /\s+/, $_, 4;
	if (@fields != 3 && @fields != 4) {
	    die <<EOF;
Cannot parse line '$_' in $clock_adjust_file, must be either

    isodate gpstime cameratime

or

    isodatetimefrom isodatetimeto gpstime cameratime

or

    isodatetimefrom isodatetimeto Ignore: reason...

EOF
	}

	# Is it the extended format ("Ignore:" ...)?
	if (@fields == 4 && $fields[2] =~ m{^(.*):$}) {
	    if ($1 ne 'Ignore') {
		die <<EOF;
Cannot parse line '$_' in $clock_adjust_file,
unhandled keyword '$1:', only 'Ignore:' is allowed.
EOF
	    }
	    # Ignore:
	    my($from, $to, undef, $reason) = @fields;
	    push @ignore_mapping, [_isodate_to_epoch($from), _isodate_to_epoch($to), $reason, %add_opts];
	} else {
	    my($from, $to, $gps, $camera);
	    if (@fields == 3) {
		$from = $fields[0] . 'T00:00:00';
		$to   = $fields[0] . 'T23:59:59';
		($gps, $camera) = @fields[1,2];
	    } else {
		($from, $to, $gps, $camera) = @fields;
	    }
	    my $from_epoch = _isodate_to_epoch $from;
	    my $to_epoch   = _isodate_to_epoch $to;
	    push @cam_delta_mapping, [$from_epoch, $to_epoch, get_cam_delta($gps, $camera), %add_opts];
	}
    }

    if ($debug) {
	require Data::Dumper;
	print STDERR "After parsing '$clock_adjust_file':\n";
	print STDERR Data::Dumper->new([\@cam_delta_mapping, \@ignore_mapping],[qw(cam_delta_mapping ignore_mapping)])->Indent(1)->Useqq(1)->Dump;
    }
}

sub _isodate_to_epoch ($) {
    my $isodate = shift;
    my($Y,$M,$D,$h,$m,$s) = $isodate =~ $lax_iso_date_qr
	or die "Cannot parse date '$isodate' as ISO date";
    timelocal($s,$m,$h,$D,$M-1,$Y-1900);
}

sub manipulate {
    my(%opts) = @_;
    my $mode = delete $opts{mode} || die "mode is mandatory";
    my $inplace;
    if ($mode eq 'inplace') {
	$inplace = 1;
    } elsif ($mode eq 'copy') {
	# Note: in this mode, $out_file is really a directory
	require File::Copy;
	if (!defined $out_file) {
	    die "Please define output directory (option -o)\n";
	}
	if (!-e $out_file) {
	    mkdir $out_file
		or die "Cannot create output directory <$out_file>: $!\n";
	} elsif (!-d $out_file) {
	    die "<$out_file> exists, but is not a directory\n";
	}
    } else {
	die "mode must be either 'copy' or 'inplace', not '$mode'";
    }

    for my $file (@images) {
	my $ret = get_image_location($file, interpolate => $do_interpolate, prefer_gps => ($allow_overwrite_gpspos ? 'track' : 'photo'));
	if (defined $ret && $ret->{Source} ne 'image') {
	    my($lat, $lon, $local_date, $gps_altitude) = @{$ret}{qw(Latitude Longitude Date Altitude)};
	    my(@lat_dms) = Karte::Polar::ddd2dms($lat);
	    my(@lon_dms) = Karte::Polar::ddd2dms($lon);

	    my $gps_latitude_ref;
	    if ($lat_dms[0] < 0) {
		$gps_latitude_ref = 'S';
		$lat_dms[0] *= -1;
	    } else {
		$gps_latitude_ref = 'N';
	    }
	    my $gps_longitude_ref;
	    if ($lon_dms[0] < 0) {
		$gps_longitude_ref = 'W';
		$lon_dms[0] *= -1;
	    } else {
		$gps_longitude_ref = 'E';
	    }

	    my $gps_latitude  = qq{$lat_dms[0] deg $lat_dms[1]' $lat_dms[2]"};
	    my $gps_longitude = qq{$lon_dms[0] deg $lon_dms[1]' $lon_dms[2]"};

	    my $gps_datetime;
	    my $gps_datestamp;
	    my $gps_timestamp;
	    if ($local_date) {
		my $epoch = _isodate_to_epoch($local_date);
		$gps_datestamp = strftime '%Y:%m:%d %H:%M:%S', gmtime $epoch;
		$gps_timestamp = strftime '%H:%M:%S', gmtime $epoch;
		$gps_datetime = "$gps_datetime $gps_timestamp";
	    }

	    if ($debug) {
		warn "Found position '$gps_latitude $gps_latitude_ref, $gps_longitude $gps_longitude_ref'"
		    . ($gps_datetime ? ", datetime '$gps_datetime'" : "")
		    . ($gps_altitude ? ", altitude '$gps_altitude'" : "")
		    . " for $file.\n";
	    }

	    my $orig_mtime = (stat($file))[9];
	    my $o_file;
	    if (!$inplace) {
		$o_file = "$out_file/" . basename($file);
		File::Copy::copy($file, $o_file)
			or die "Can't $file to $o_file: $!";
	    } else {
		$o_file = $file;
	    }
	    my $exiftool = Image::ExifTool->new;
	    $exiftool->ExtractInfo($o_file);
	    $exiftool->SetNewValue('GPSLatitude',     $gps_latitude);
	    $exiftool->SetNewValue('GPSLatitudeRef',  $gps_latitude_ref);
	    $exiftool->SetNewValue('GPSLongitude',    $gps_longitude);
	    $exiftool->SetNewValue('GPSLongitudeRef', $gps_longitude_ref);
	    if ($gps_datetime) {
		$exiftool->SetNewValue('GPSDateTime', $gps_datetime);
		$exiftool->SetNewValue('GPSDateStamp', $gps_datestamp);
		$exiftool->SetNewValue('GPSTimeStamp', $gps_timestamp);
	    }
	    if ($gps_altitude) {
		if ($gps_altitude >= 0) {
		    $exiftool->SetNewValue('GPSAltitude', $gps_altitude);
		    $exiftool->SetNewValue('GPSAltitudeRef', 'Above Sea Level');
		} else {
		    $exiftool->SetNewValue('GPSAltitude', -$gps_altitude);
		    $exiftool->SetNewValue('GPSAltitudeRef', 'Below Sea Level');
		}
	    }
	    $exiftool->WriteInfo($o_file);
	    utime $orig_mtime, $orig_mtime, $o_file;
	    if ($debug) {
		warn "Wrote GPS info to $o_file.\n";
	    }
	}
    }
}

__END__

=encoding iso-8859-1

=head1 NAME

geocode_images - geocode images against a gps track data base

=head1 SYNOPSIS

See L</EXAMPLES>

=head1 DESCRIPTION

With geocode_images it is possible to create a connection between exif
coded images and a database of GPS tracks. The connection is done
through the date.

For now, it is assumed that the GPS track database is just a directory
with GPSMAN track files, where filenames are named I<YYYYMMDD.trk> or
I<YYYY-MM-DD.trk>.

=head2 Adjusting clocks

For successful geocoding it is necessary to have synchronized clocks
in the GPS receiver and the camera. The GPS clock is very accurate,
but the camera clock usually is not. A nice technique is too make a
photo of the GPS device while displaying the time (including
seconds!), later determine the camera clock of this photo, and use
this for the -clock* options:

    geocode_images -clockgps HH:MM:SS -clockcamera HH:MM:SS ...

where the -clockgps value is what is visible on the photo and the
-clockcamera value is what is visible in the EXIF data of the photo.

It is also possible to maintain a database of such clock adjustments
in a file (which is specified with the C<-clockadjustfile> option).
This is just a text file with whitespace-separated fields, either:

   ISO_date_time_from	ISO_date_time_to	gps_time	camera_time

or

   ISO_date	gps_time	camera_time

(ISO date time is here YYYYMMDDTHHMMSS, with possible separators, and
ISO date is YYYYMMDD, with possible separators).

The first field or first two fields denote the validity time of the
adjustment.

There's currently no time zone support. Currently it is assumed that
the times of the photo files have the local time zone. This may change
in the future.

=head2 Ignoring photo files

The C<-clockadjustfile> file can also be used to ignore photo files
completely. Just put lines in the form

   ISO_date_time_from	ISO_date_time_to	Ignore:	some text explaining the reason

with the string "Ignore:" in the third field.

=head2 Multiple cameras in one clockadjustfile

The C<-clockadjustfile> file can also be used to manage multiple
cameras. Use a directive in the following format (but without the
"E<lt>" and "E<gt>"):

   #: camera: <Make> <Camera Model Name>

to signal that all following lines are valid only for the mentioned
camera. E<lt>MakeE<gt> and E<lt>Camera Model NameE<gt> are EXIF
fields. An example:

   #: camera: Nokia N95

To remove a camera restriction for following lines, use the empty
camera directive:

   #: camera:

=head1 EXAMPLES

Assuming photos are located in files and subdirectories of the
directory ~/images, the gps tracks are in the default location
(subdirectory misc/gps_data relative to the bbbike source directory),
and the generated bbd should go to /tmp:

    ~/src/bbbike/miscsrc/geocode_images -o /tmp/geocoded_images.bbd ~/images

The directory of gps tracks may be changed with the C<-gpsdatadir> option.

Or using a "find" before:

    find ~/images/scans/Baltikum-02/baltics-bestof/ -name "*.jpg" -print0 | xargs -0 ~/src/bbbike/miscsrc/geocode_images -v -gpsdatadir ~/src/bbbike/misc/gps_data/polen_baltikum/ > /tmp/5.bbd

    cd ~/images/from_handy/Fotos/ && find . -name "*.jpg" -print0 | xargs -0 ~/src/bbbike/miscsrc/geocode_images > /tmp/1.bbd

Or put instructions in a makefile:

    geocode-rj1:
	mkdir -p rj-fotos/.xvpics
	$(HOME)/src/bbbike/miscsrc/geocode_images \
	    -gpsdatadir $(HOME)/src/bbbike/misc/gps_data/other \
	    -thumbnaildir rj-fotos/.xvpics \
	    -converter fast \
	    -clockgps 08:52:02 -clockcamera 08:49:43 \
	    -v -anchor nw -relativepaths \
	    rj-fotos/*.jpg > rj-fotos.bbd

B<-relativepaths> assumes that thumbnaildir, the specified photos and
the generated bbd file are relative to each other.

The B<-dateindex I</tmp/gpsdateindex.db>> option assumes that a GPS
index was build before with L<create_track_index.pl>.

An update of an existing bbd file can be done using C<-o file.bbd
-update>.

=head1 HOWTO

To display geocoded thumbnails in bbbike:

=over

=item * Start bbbike

=item * Load the C<BBBikeViewImages> plugin using B<Plugins> E<gt>
B<Alle Plugins zeigen>.

=item * Create a bbd file like explained in L</EXAMPLES>.

=item * Load the bbd file in bbbike: B<Kartenebenen> E<gt>
B<Zustzlich zeichnen> E<gt> B<Straen-Layer zeichnen>

=item * If necessary, then adjust the scrollregion (in the same menu)

=item * If the plugin was loaded and activated ("View Images mode"),
then it is possible to click on the thumbnails to get additional
information about the image (EXIF data) or the larger version

=back

=head1 TODO

 * Instructions needed on how to create
   a bundle suitable for sharing with other people
 * GPX support
 * document options!
   * -person: use only GPS tracks with no srt:person tag or a matching srt:person tag

=head1 SEE ALSO

L<create_track_index.pl>

=cut
