Newer
Older
tools / geomapper.py
@HIROSE Yuuji HIROSE Yuuji on 19 Feb 2021 17 KB chmod +x
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# geomapper.py - (C)2016-2019 by HIROSE, Yuuji <yuuji(at)yatex.org>
# http://www.gentei.org/~yuuji/software/geomapper/
# $HGid: geomapper.py,v 7:eaf4f4b28afd 2019-08-13 10:25 +0900 yuuji $
#
# NOTE: This script required the following software.
#	Python, jhead, GPS Babel
import sys, re, time, datetime, os.path, json, pprint, shutil
from subprocess import Popen, PIPE, check_output

omnivore0 = """
<script src="https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-omnivore/v0.3.1/leaflet-omnivore.min.js"></script>
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Promise"></script>
""".strip()
omnivore = """
<script src="https://www.yatex.org/libcache/leaflet-omnivore/tags/v0.3.2/leaflet-omnivore.min.js"></script>
""".strip()
bing = """
<script src="https://www.yatex.org/libcache/leaflet-plugins-2.0.0/layer/tile/Bing.js"></script>
""".strip()

google0 = """
<script src="https://maps.google.com/maps/api/js?key=AIzaSyCKJ-CvuAuD9QovJ0EwLfsxGkERnF-T5i0&v=3.20&sensor=false"></script>
<script src="https://rawgithub.com/shramov/leaflet-plugins/master/layer/tile/Google.js"></script>
""".strip()
google = """
<script src="https://maps.google.com/maps/api/js?key=AIzaSyCKJ-CvuAuD9QovJ0EwLfsxGkERnF-T5i0&v=3.20&sensor=false"></script>
<script src="https://www.yatex.org/libcache/leaflet-plugins-2.0.0/layer/tile/Google.js"></script>
""".strip()
gpx = """
<script src="https://www.yatex.org/libcache/gpx.js"></script>
""".strip()

#tmpl = File.expand_path(File.basename($0, ".rb")+".tmpl", File.dirname($0))
title = "Photo Map"
colors = ["red", "green", "blue", "purple", "yellow", "cyan", "magenta"]
zoom = "10"
weight = "8"
outfile = None
quiet = False
usegmap = False
usebing = True
usexml  = False
slon = slat = cuthead = cuttail = None
cuth_r = cutt_r = 800.0
# taiofs: 1388534400==2014/1/1 @ nonTAI system
taiofs = int(time.mktime((2014,1,1,0,0,0,0,0,0))-time.timezone)-1388534400
commentfile = None
comment = {}
logs  = []
ologs = []
jsons = []
jpg   = []
lonlatround = 4                 # Round digits after decimal point
featurefile = "output.geojson"
umapjs="umappop.js"

# Argument parsing
ARGV=sys.argv
argv0 = ARGV.pop(0)
while ARGV:
    a=ARGV[0]
    if a=='-g':
        usegmap = True
    elif a=='-b':
        usebing = True
    elif a=='-o':
        outfile = ARGV[1]; ARGV.pop(0)
    elif a=='-C':
        commentfile = ARGV[1]; ARGV.pop(0)
    elif a=='-c':
        colors = ARGV[1].split(","); ARGV.pop(0)
    elif a=='-T':
        taiofs = int(ARGV[1]); ARGV.pop(0)
    elif a=='-t':
        title = ARGV[1]; ARGV.pop(0)
    elif a=='-z':
        zoom = ARGV[1]; ARGV.pop(0)
    elif a=='-r':
        lonlatround = int(ARGV[1]); ARGV.pop(0)
    elif a=='-w':
        weight = ARGV[1]; ARGV.pop(0)
    elif a=='-j':
        umapjs = ARGV[1]; ARGV.pop(0)
    elif a[0:2]=='-p' or a[0:2]=='-P':
        m=re.match("-([Pp])(\d+)?", a)
        if m.group(1) == 'p':
            cuthead = True
        else:
            cuttail = True
        if m.group(2):
            if m.group(1)=='p':
                cuth_r = float(m.group(1))
            else:
                cutt_r = float(m.group(1))
        elif len(ARGV)>1 and re.match("^(\d+)$", ARGV[1]):
            if m.group(1)=='p':
                cuth_r = float(ARGV[1]); ARGV.pop(0)
            else:
                cutt_r = float(ARGV[1]); ARGV.pop(0)
    elif a=="-q":
        quiet = True
    elif re.search("\.(gpx|kml)$", a, re.I):
        logs.append(ARGV[0]); usexml = True
        from xml.dom import minidom
    elif re.search("\.jpe?g$", a, re.I):
        jpg.append(ARGV[0])
    elif re.search("\.(geojson|umap)", a, re.I):
        color = colors.pop(0)
        jsons.append({'file': ARGV[0], 'color': color, 'weight': weight})
        colors.append(color)
    else:
        sys.stderr.write("Argument [%s], ignored\n" % ARGV[0])
    ARGV.pop(0)

myname = os.path.basename(argv0)
mydir  = os.path.dirname(argv0)
if not logs and not jpg and not jsons:
  sys.stderr.write("""
{myname} - Making photo-map from GPX/KML logs and photos
Usage:
	% {myname} [options] [GPSLogfiles] [Photo files with EXIF]

Options are as follows:
	-g		GoogleMaps for additional layer
	-b		BingMaps for additional layer
	-j UMAPPOPjs	Use UMAPPOPjs javascript file instead of {umapjs}
	-o File		Output HTML files to `File'
	-C CmntTxt	Read `CmntTxt' as comment txt(*1)
-c Col1,Col2,..	Set GPSLog path color list to comma separeted list
	-t Title	Set HTML title to `Title'
-z Zoom		Set default zoom level without GPS log
	-r D		Round Lon/Lat off to (D-1) decimal places
	-p		Privacy mode: cut GPS log within {cuth_r}m at start
	-pN		Privacy mode: cut GPS log within `N` meter at start
	-P		Privacy mode: cut GPS log within {cutt_r}m at goal
	-PN		Privacy mode: cut GPS log within `N' meter at goal
	-T TAIoffset	Set TAI offset(defaults to {taiofs})

Note that JPEG  file SHOULD contain EXIF information.
JPEG files are directly linked to PopUp-s in a map.
You might want to shrink jpeg files suitable for Web.
Beware your graphic editor might drop EXIF after resizing operations.
To keep EXIF in shrinked jpeg files, consider to use these:
	GIMP (w/EXIF extension) http://www.gimp.org/
	rjpg	http://www.gentei.org/~yuuji/software/rjpg

(*1) Format of comment text is a repetition of `FileName<BLANK>Comment'.
Comment part should be written within a line.
(eg.)
	file1.jpg	Comment 1
	file2.jpg	Comment 2
	file3.jpg	Comment 3
""".format(**locals()))
  quit()

outfile = os.path.splitext(
    outfile or (logs and logs[0]) or (jsons and jsons[0]['file']) or myname
)[0] + ".html"
outdir = os.path.dirname(outfile)

# for calculating distance
def distance(lon0, lat0, lon1, lat1):
    import math, os
    rx = 6378.137		# (km)
    ry = 6356.752314            # (km)
    esq=(rx**2-ry**2)/rx**2
    rSphere = 40000.0/2/math.pi
    dlon = (lon1-lon0)*math.pi/180
    dlat = (lat1-lat0)*math.pi/180
    avlat = (lat0+lat1)/2.0*math.pi/180
    if os.environ.get("HUBENY"):
        # http://hp.vector.co.jp/authors/VA002244/yacht/geo.htm
        w = math.sqrt(1-esq*math.sin(avlat)**2)
        m = rx*(1-esq)/w**3
        n = rx/w
        return math.sqrt((dlat*m)**2 + (dlon*n*math.cos(avlat))**2)
    else:
        return rSphere*math.sqrt((dlon*math.cos(avlat))**2 + dlat**2)

def n2s(n, base=10):
    bs = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    if base>len(bs) or base<2:
        return None
    s=""
    while n>0:
        r = n%base
        s = bs[r] + s
        n //= base
    return s

trk = []                        # Keep trk list
legend = ""
for gpslog in logs:
    fmt = os.path.splitext(gpslog)[1][1:]
    llon = llat = None
    dist = 0.0
    thistrk = []
    gpsstat = os.stat(gpslog)
    ntgps = n2s(gpsstat.st_dev, 36)+"-"+n2s(gpsstat.st_ino, 36)+".o"+fmt
    cmd = ['gpsbabel', '-i', fmt, '-o', 'ricoh', '-f', gpslog,
           '-x', 'simplify,error=4m',
           '-F', '-']
    with Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) as p:
        # 139.862170,38.942190,-2.000000,-2.000000,01-07-2014 00:51:31

        # import fcntl
        # fl = fcntl.fcntl(p.stdout.fileno(), fcntl.F_GETFL)
        # fl = fl | os.O_NONBLOCK
        # fcntl.fcntl(p.stdout.fileno(), fcntl.F_SETFL, fl)
        # fl = fcntl.fcntl(p.stderr.fileno(), fcntl.F_GETFL)
        # fl = fl | os.O_NONBLOCK
        # fcntl.fcntl(p.stderr.fileno(), fcntl.F_SETFL, fl)
        # setblocking(p.stderr)
        def readstderr(fd):
            while True:
                try:
                    line = fd.readline()
                    # sys.stderr.write("E")
                except Exception as e:
                    # str(type(e))), str(e.args)), str(e))
                    break;
        import threading
        threading.Thread(target=readstderr, args=(p.stderr,)).start()
        while True:
            track=p.stdout.readline()
            if track:
                lon, lat, e1, e2, tm = track.decode('ascii').split(",")
                tmf = datetime.datetime.strptime(tm.strip(), "%d-%m-%Y %H:%M:%S")
                tmg = time.mktime(tmf.timetuple())-time.timezone
                thistrk.append([float(lon), float(lat),
                         float(e1),  float(e2), float(tmg)])
                if llon:
                    dist += distance(float(llon), float(llat),
                                     float(lon), float(lat))
                llon, llat = lon, lat
            #elif p.stderr.readline():
            #    sys.stderr.write("E") # put E without NL
            else:
                break           # EOF
    starttm = 0
    stoptm = int(datetime.datetime.now().strftime("%s"))	# Set max time
    if cuthead:
        # p Time.at(thistrk[0][-1]).utc.strftime("%Y%m%d%H%M%H")
        lon0, lat0, i = thistrk[0][0], thistrk[0][1], 1
        while distance(lon0, lat0, thistrk[i][0], thistrk[i][1]) < cuth_r/1000:
            i+=1
            if i>=len(thistrk)-1:
                break 
        if i != len(thistrk)-1:    # if found
            newtrktime = thistrk[i][-1]+taiofs
            starttm = newtrktime
    if cuttail:
        lonF, latF, i = thistrk[-1][0], thistrk[-1][1], len(thistrk)-1
        while distance(lonF, latF, thistrk[i][0], thistrk[i][1]) < cutt_r/1000:
            i-=1
            if i < 0:
                break
        if i >= 0:              # if found
            newtrktime = thistrk[i][-1]+taiofs
            stoptm = newtrktime
    xml = minidom.parse(gpslog)
    node = xml.getElementsByTagName("Document") or \
           xml.getElementsByTagName("trk") or \
           xml.getElementsByTagName("gpx")

    if node:
        name = node[0].getElementsByTagName("name")
        label = (name[0].childNodes[0].data if name else
                 os.path.splitext(gpslog)[0])
        if name:
            name[0].childNodes[0].data = label
        else:
            newname = xml.createElement("name")
            newname.appendChild(xml.createTextNode(label))
            node[0].insertBefore(newname, node[0].firstChild)
        # Remove length==1 trkseg, bacause omnivore hangs
        for node in xml.getElementsByTagName("trkseg"):
            # print("LEN=", len(node.childNodes))
            if 1 == len(node.getElementsByTagName("trkpt")):
                node.parentNode.removeChild(node)
        # Remove privacy information
        rmcnt = 0
        for node in xml.getElementsByTagName("trkpt"):
            for t in node.getElementsByTagName("time"):
                utc = t.firstChild.data.replace("Z", "") # Ugly...
                dtm = datetime.datetime.strptime(utc, "%Y-%m-%dT%H:%M:%S.%f")
                epotm = time.mktime(dtm.timetuple())-time.timezone
                if epotm < starttm or epotm > stoptm:
                    rmcnt += 1
                    # print("%3d:Removing utc=%s, tm=%s\n" % (rmcnt, utc, t.firstChild.data))
                    node.parentNode.removeChild(node)
        if not quiet and rmcnt > 0:
            sys.stderr.write("%d points removed for privacy\n" % rmcnt)
        # Remove naive information
        times = (xml.getElementsByTagName("time")
                 + xml.getElementsByTagName("speed"))
        for node in times:
            node.parentNode.removeChild(node)
        with open(ntgps, "w") as nt:
            # nt.write(xml.toprettyxml(' ', '\n', 'utf-8').decode())
            nt.write(xml.toxml('utf-8').decode())

    trk += thistrk
    diststr = "(%.1fkm)" % dist if dist > 1.0 else "(%dm)" % (dist*1000)
    title = re.sub(re.compile('\.[a-z]+$', re.I), "", gpslog) + diststr
    color = colors.pop(0)
    colors.append(color)
    legend += ('<span style="border: solid thick %s">%s</span>'%(color, title))
    ologs.append({'file': ntgps, 'color': color, 'weight': weight})
    # colors << (color = colors.shift)

###### Comment file parsing
if commentfile:
    with open(commentfile) as cm:
        for line in cm:
            m = re.findall('(\S+)\s+(.*)', line)
            if m:
                f, c = m[0]
                if os.path.exists(f):
                    comment[f] = c

longedge = 400
photo = {}
i = 0

#printf("time range %s to %s", Time.at(trk[0][-1]).to_s, Time.at(trk[-1][-1]))
jpegpos = {}
jpeginf = {}
for ph in jpg:
    exif = check_output(['jhead', ph]).decode("utf-8").split("\n")
    pt = ([x for x in exif if re.match('Date/Time', x)] or
          [x for x in exif if re.match('File date', x)])
    if not pt:
        sys.stderr.write("[%s] has no Exif Date/Time information, skipped\n"
                         % ph)
        next
    pt = re.sub('^[^:]+:\s*', '', pt[0])
    if pt == '':
        next
    pt = re.sub('(\d\d\d\d):(\d\d):(\d\d)', '\\1-\\2-\\3', pt)
    # sigh...for poooor time handling

    ptime = datetime.datetime.strptime(pt, "%Y-%m-%d %H:%M:%S")
    ptime = ptime.timetuple()		# We assume the photo timestamp is
    ptime = float(time.mktime(ptime))	# represented as localtime
    res = [x for x in exif if x.startswith('Resolution')]\
          [0].split(':')[1].strip()
    # Calculate width/height for displaying (not shrikned actually)
    width, height = res.split('x')
    x, y = int(width), int(height)
    if x > y:
        w = longedge
        h = w*y/x
    else:
        h = longedge
        w = h*x/y

    gpslat = [x for x in exif if x.startswith("GPS Latitude")]
    gpslon = [x for x in exif if x.startswith("GPS Longitude:")]
    gpsalt = [x for x in exif if x.startswith("GPS Altitude")]
    # GPS Latitude : N 38d 54m 54.187s
    # GPS Longitude: E 139d 50m 14.5284s
    if gpslat and gpslon:
        m = re.search('[N?]\s+(\d+)d\s+([0-9.]+)m\s+([.0-9]+)s', gpslat[0])
        gpslat = (float(m.group(1)) + float(m.group(2))/60 +
                        float(m.group(3))/3600) if m else None
        m = re.search('[E?]\s+(\d+)d\s+([0-9.]+)m\s+([.0-9]+)s', gpslon[0])
        gpslon = (float(m.group(1)) + float(m.group(2))/60 +
                        float(m.group(3))/3600) if m else None
        if gpsalt:
            m = re.search('\s+([0-9.]+)m', gpsalt[0])
            gpsalt = float(m.group(1)) if m else None
        else:
            gpsalt = None

    html = os.path.splitext(ph)[0] + ".html"
    link = html if os.path.exists(html) else ph
    jpeginf[ph] = {"desc": comment[ph] if comment.get(ph) else pt}
    if gpslat and gpslon:
        # When photo itself has geoinfo
        trk.append([gpslon, gpslat, gpsalt, gpsalt, ptime])
        photo[ph] = trk[-1]
    else:
        if ptime < trk[0][-1] or ptime > trk[-1][-1]:
            sys.stderr.write(
                "[%s] is NOT taken within GPS trip term, skipped\n" % ph)
            next
        photo[ph] = sorted(trk, key=lambda x: (x[-1]-ptime)**2)[0]
        # print(photo[ph])

    rlon = round(photo[ph][0], lonlatround)
    rlat = round(photo[ph][1], lonlatround)
    rpos = str(rlon) + "," + str(rlat)
    if not jpegpos.get(rpos):
        jpegpos[rpos] = []
    jpegpos[rpos].append([ph, w, h])
    i+=1

features = []
## print(jpeginf)
for pos, jpegs in jpegpos.items():
    rlon, rlat = pos.split(",")
    files = [j[0] for j in jpegs]
    desc = "\n".join([(jpeginf[j]["desc"] + "\n" + "[[%s|{{%s}}]]"%(j,j))
                      for j in files])
    # print(jpegs)
    name = ", ".join([os.path.splitext(j[0])[0] for j in jpegs])
    features.append({
        "type":		"Feature",
        "geometry":	{
            "type": "Point", "coordinates": [float(rlon), float(rlat)]},
        "properties":	{"name": name, "description": desc}
    })

geojson = {
    "type":	"FeatureCollection",
    "name":	"Photo List",
    "features":	features
}
if features:
    with open(featurefile, "w") as o:
        # Unsetting ensure_ascii for KanjiChars
        o.write(json.dumps(geojson, indent=1, ensure_ascii=False))
    jsons.append({'file':featurefile})

template = """
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<!-- <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.css" /> -->
<link rel="stylesheet" href="https://www.yatex.org/libcache/leaflet-1.3.2/leaflet.css" />

<!--  <script src="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.js"></script> -->
<script src="https://www.yatex.org/libcache/leaflet-1.3.2/leaflet.js"></script>

_BING_
_GOOGLE_
_GPX_
<style type="text/css">
<!--
 img.popup {width: 240px;}
 div#map {height: 700px;}
 .leaflet-popup-content {max-height: 300px; overflow: auto;}
 .leaflet-popup-content p {white-space: pre-wrap;}
 -->
</style>

    <title>_TITLE_</title>
<script type="text/javascript" src="%s" charset="utf-8"></script>
</head>

<body onload="umappop(_INIT_);">
    <p>_LEGEND_ <span id="latlng"></span></p>
    <div id="map"></div>
</body>
</html>
""".strip() % umapjs		# src="umappop.js"

# print(diststr)
initargs = pprint.pformat(jsons+ologs)
if not quiet:
    print('Output to "%s" - %s' % (outfile, initargs))

with open(outfile, "w") as w:
    _g = google if usegmap else ""
    _b = bing if usebing else ""
    _x = omnivore if usexml else ""
    w.write(template.replace("_TITLE_", title).
            replace("_LEGEND_", legend).
            replace("_INIT_", initargs).
            replace("_GOOGLE_", _g).
            replace("_BING_", _b).
            replace("_GPX_", _x))

umappop = os.path.join(outdir, umapjs)
if not os.path.exists(umappop):
    try:
        relp = os.path.relpath(os.path.join(mydir, umapjs), outdir)
        os.link(relp, umappop)
    except Exception as e:
        try:
            shutil.copy(relp, umappop) 
        except Exception as e:
            sys.stderr.write("Cannot create symlink or file of "+umapjs)