MENU

【Python実践】GPS位置情報で車両の軌跡を分析しよう!

工場で使用されるフォークリフトでは、稼働率の把握や経路の最適化を行うために、GPSなどの位置情報を利用することがあります。また、建設や農業で使用される特殊車両では、テレマティクスで収集した位置情報を活用し、顧客満足度の向上を図る試みが行われています。

このように製造業にとってもGPS(位置情報)を分析することは非常に重要です。

本記事では、folium ライブラリを使ったGPS位置情報の可視化(移動軌跡を地図にプロット)方法と、機械学習を行うための特徴量を導き出す方法について、具体的なサンプルプログラムを交えて紹介します。

GPSを使って車両の軌跡を分析したい方は、是非参考にしてください。

目次

Folium によるGPS位置情報の可視化

Folium とは

Foliumは、Pythonプログラミング言語を使用してインタラクティブな地図を作成するためのライブラリです。主にLeaflet.jsというオープンソースのJavaScriptライブラリを基にしており、簡単に地理データを視覚化するための強力なツールです。以下は、Foliumの主な特徴です。

  • インタラクティブな地図
    Foliumを使用すると、インタラクティブな地図を簡単に作成できます。地図にズームイン・アウトしたり、移動したりすることができます。
  • 豊富なタイルオプション
    OpenStreetMap、Mapbox、Stamenなど、様々な地図タイルを使用できます。また、自分でカスタムタイルを定義することも可能です。
  • データ可視化
    線(ポリライン)、ポリゴン、ポイント(マーカー)、サークルなど、様々な地理データを地図上にプロットできます。
  • 簡単な操作
    Pythonコードで地図を作成し、HTMLファイルとして保存できます。保存されたHTMLファイルはウェブブラウザで表示可能です。

FoliumによるGPSの可視化

folium を使うには、あらかじめ pip でインストールしておく必要があります。

pip install folium

Foium の仕様やメソッドの詳しい情報が必要な方は、Foium 公式ページのUser Guide をご確認下さい。

下記は、GPS軌跡、サークル、マーカーを重ね合わせて表示するサンプルです。
folium.Map() で地図を表示し、PolyLine() でGPSの軌跡を、Circle() で円を、Marker() でマーカーを重ね合わせします。

import pandas as pd
import numpy as np

def calc_gps_features(df, timestamp_col, lat_col, lon_col):
    """
    GPSデータから特徴量(距離、速度、加速度、角度)を計算する関数

    :param df: GPSデータを含むDataFrame
    :param timestamp_col: タイムスタンプのカラム名
    :param lat_col: 緯度のカラム名
    :param lon_col: 経度のカラム名
    :return: 特徴量を計算したDataFrame
    """
    # Haversine 公式による2つのGPS座標間の距離計算関数
    def haversine(lat1, lon1, lat2, lon2):
        R = 6371  # 地球の半径(km)
        d_lat = np.radians(lat2 - lat1)
        d_lon = np.radians(lon2 - lon1)
        a = np.sin(d_lat / 2) ** 2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(d_lon / 2) ** 2
        c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
        return R * c

    # 2点間の角度を計算する関数
    def calculate_angle(lat1, lon1, lat2, lon2):
        return np.arctan2(lat2 - lat1, lon2 - lon1) * 180 / np.pi

    # 各ポイント間の距離を計算
    df['shifted_latitude'] = df[lat_col].shift(-1)
    df['shifted_longitude'] = df[lon_col].shift(-1)
    df['distance'] = df.apply(lambda row: haversine(row[lat_col], row[lon_col],
                                                    row['shifted_latitude'], row['shifted_longitude']), axis=1)

    # 各ポイント間の時間差(秒)を計算
    df['shifted_timestamp'] = df[timestamp_col].shift(-1)
    df['time_diff'] = (df['shifted_timestamp'] - df[timestamp_col]).dt.total_seconds()

    # 各ポイント間の速度(km/h)を計算
    df['speed'] = df['distance'] / (df['time_diff'] / 3600)  # km/h

    # 各ポイント間の加速度(km/h^2)を計算
    df['shifted_speed'] = df['speed'].shift(-1)
    df['acceleration'] = (df['shifted_speed'] - df['speed']) / (df['time_diff'] / 3600)  # km/h^2

    # 各ポイント間の角度を計算
    df['angle'] = df.apply(lambda row: calculate_angle(row[lat_col], row[lon_col],
                                                       row['shifted_latitude'], row['shifted_longitude']), axis=1)

    # 無効な値を含む最後の行を削除
    df = df.dropna()

    return df[[timestamp_col, 'distance', 'speed', 'acceleration', 'angle']]

#====================================================================================
# 使用例
#====================================================================================
data = {
    'timestamp': ['2022-11-25 08:00', '2022-11-25 08:05', '2022-11-25 08:10',
                  '2022-11-25 09:30', '2022-11-25 09:35', '2022-11-25 12:00',
                  '2022-11-25 12:05', '2022-11-25 14:30', '2022-11-25 14:35', '2022-11-25 17:00'],
    'latitude': [35.6899, 35.6895, 35.6898, 35.6902, 35.6904, 35.6902, 35.6906, 35.6909, 35.6910, 35.6913],
    'longitude': [139.6917, 139.6919, 139.6921, 139.6923, 139.6925, 139.6927, 139.6929, 139.6931, 139.6933, 139.6935]
}
df = pd.DataFrame(data)
df['timestamp'] = pd.to_datetime(df['timestamp'])

# 特徴量を計算
features_df = calc_gps_features(df, 'timestamp', 'latitude', 'longitude')
print(features_df)

#====================================================================================
# GPS の可視化
#====================================================================================
import folium
import webbrowser
import os

def mapping(df,lat_col,long_col,temp_file='gps_track.html'):  
    map = folium.Map(location=[df[lat_col].mean(), df[long_col].mean()],zoom_start=18,
                     tiles='https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
                     attr='世界衛星モザイク画像')
    folium.PolyLine(df[[lat_col, long_col]].values, color='blue').add_to(map)
    map.save(temp_file) 
    webbrowser.open('file://' + os.path.realpath(temp_file))

# GPSの軌跡を地図にマッピング
mapping(df,'latitude','longitude')

Folium で使える地図

下記は国土交通省が公開している地図のURLです。

folium.Map() tilesに地図タイルのURLを、attrに任意の文字列を指定することで、地図が表示できます。attr省略するとエラーになるので、必ず何らかの文字列をセットしてください。

folium.Map(location=[center_lat, center_lon], zoom_start=18, tiles=”URL”, attr=”任意の文字列”)


国土交通省のサイトを表示し、少しスクロールすると下記の表記が見つかります。下記の赤枠で囲ったURLをtilesに、attrには後から分かり易いようにデータソース名を指定しておくのが無難です。

先ほどのサンプルでは、下記のURLとデータソースを指定しています。

地図タイルには、フリーのものが他にも沢山存在します。
たとえば、下記サイトにも有名なサイトが複数掲載されています。

あわせて読みたい

GPSから特徴量を導き出す

GPSには通常データ発生時刻が付属します。このデータ発生時刻を活用することで、GPSデータから速度、加速度、移動距離、角度などの情報が抽出できます。そして、これらの情報を元に統計情報を計算することで、機械学習で利用可能な特徴量を導きだすことができます。

GPS軌跡情報から移動距離、速度、加速度、角度を計算する

下記は、引数に指定したDataFrameから、移動距離(distance)、速度(speed)、加速度(acceleration)、角度(angle)、進行方向を0度とした時の相対角度(relative_angle)等の数値をを計算するcalc_gps_features()関数のサンプルです。

import pandas as pd
import numpy as np

def calc_gps_features(df, timestamp_col, lat_col, lon_col,angle_threshold=10):
    """
    GPSデータから特徴量(距離、速度、加速度、角度)を計算する関数

    :param df: GPSデータを含むDataFrame
    :param timestamp_col: タイムスタンプのカラム名
    :param lat_col: 緯度のカラム名
    :param lon_col: 経度のカラム名
    :param angle_threshold: 直線と見なす角度の変化の閾値(度)
    :return: 特徴量を計算したDataFrame
    """
    # Haversine 公式による2つのGPS座標間の距離計算関数
    def haversine(lat1, lon1, lat2, lon2):
        R = 6371  # 地球の半径(km)
        d_lat = np.radians(lat2 - lat1)
        d_lon = np.radians(lon2 - lon1)
        a = np.sin(d_lat / 2) ** 2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(d_lon / 2) ** 2
        c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
        return R * c * 1000

    # 2点間の角度を計算する関数
    def calculate_angle(lat1, lon1, lat2, lon2):
        """
        2点間の角度を計算する

        :param lat1: 最初の地点の緯度
        :param lon1: 最初の地点の経度
        :param lat2: 2番目の地点の緯度
        :param lon2: 2番目の地点の経度
        :return: 角度(度数法)
        """
        d_lon = np.radians(lon2 - lon1)
        lat1 = np.radians(lat1)
        lat2 = np.radians(lat2)
        
        x = np.sin(d_lon) * np.cos(lat2)
        y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(d_lon)
        
        bearing = np.degrees(np.arctan2(x, y))
        return (bearing + 360) % 360

    # 各ポイント間の距離を計算
    df['shifted_latitude'] = df[lat_col].shift(-1)
    df['shifted_longitude'] = df[lon_col].shift(-1)
    df['distance'] = df.apply(lambda row: haversine(row[lat_col], row[lon_col],
                                                    row['shifted_latitude'], row['shifted_longitude']), axis=1)   

    # 各ポイント間の時間差(秒)を計算
    df['shifted_timestamp'] = df[timestamp_col].shift(-1)
    df['time_diff'] = (df['shifted_timestamp'] - df[timestamp_col]).dt.total_seconds()

    # 各ポイント間の速度(km/h)を計算
    df['speed'] = (df['distance'] / df['time_diff'] ) * 3600 / 1000
    # 各ポイント間の加速度(km/h^2)を計算
    df['shifted_speed'] = df['speed'].shift(-1)
    df['acceleration'] = (df['shifted_speed'] - df['speed']) / (df['time_diff'] / 3600)  # km/h^2

    # 各ポイント間の角度を計算
    df['angle'] = df.apply(lambda row: calculate_angle(row[lat_col], row[lon_col],
                                                       row['shifted_latitude'], row['shifted_longitude']), axis=1)
    
    # 角度の変化を計算する
    relative_angle = np.diff(df['angle'])
    relative_angle = np.insert(relative_angle, 0, 0)  # 最初の要素を0で補完
    df['diff_angle'] = relative_angle

    # DataFrameの長さに合わせて補正
    df['relative_angle'] = relative_angle

    # プラスの変化があれば 360 を引く
    df['relative_angle'] = np.where(df['diff_angle'] > 270, df['diff_angle'] - 270, 
                                np.where(df['diff_angle'] > 180, df['diff_angle'] - 180, df['diff_angle'] * -1))

    # 直線の連番を付与する
    line_number = []
    current_line = 0
    for i in range(len(df)):
        if abs(df['diff_angle'].iloc[i]) <= angle_threshold:
            line_number.append(current_line)
        else:
            current_line += 1
            line_number.append(current_line)

    df['line_number'] = line_number

    # 無効な値を含む最後の行を削除
    df = df.iloc[:-1]

    return df[['timestamp', 'latitude','longitude','time_diff','distance', 'speed', 'acceleration', 'angle', 'diff_angle','relative_angle','line_number']]


#====================================================================================
# 使用例
#====================================================================================
data = {
    'timestamp': ['2022-11-25 08:00', '2022-11-25 08:05', '2022-11-25 08:10',
                  '2022-11-25 09:30', '2022-11-25 09:35', '2022-11-25 12:00',
                  '2022-11-25 12:05', '2022-11-25 14:30', '2022-11-25 14:35', 
                  '2022-11-25 17:00', '2022-11-25 17:50','2022-11-25 18:10'],
    'latitude': [35.6900, 35.6894, 35.6891, 35.6902, 35.6902, 35.6895, 35.6904, 35.6907, 35.6910, 35.6914, 35.6912, 35.6915],
    'longitude': [139.6920, 139.6919, 139.6921, 139.6923, 139.6925, 139.6928, 139.6929, 139.6930, 139.6935, 139.6935, 139.6933, 139.6930]
}
df = pd.DataFrame(data)
df['timestamp'] = pd.to_datetime(df['timestamp'])

# 特徴量を計算
features_df = calc_gps_features(df, 'timestamp', 'latitude', 'longitude')
print(features_df)

#====================================================================================
# GPS の可視化
#====================================================================================
import folium
from folium import plugins
import webbrowser
import os

def mapping(df,lat_col,long_col,temp_file='gps_track.html'):  
    map = folium.Map(location=[df[lat_col].mean(), df[long_col].mean()],zoom_start=18,
                     tiles='https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
                     attr='全国最新写真(シームレス')
    layer = folium.FeatureGroup()
    polyline = folium.PolyLine(df[[lat_col, long_col]].values, color='blue').add_to(map)
    plugins.PolyLineTextPath(polyline, '   ►   ', repeat=True, attributes={'fill':'#FF0'}).add_to(layer)
    layer.add_to(map)
    map.save(temp_file) 
    webbrowser.open('file://' + os.path.realpath(temp_file))

# GPSの軌跡を地図にマッピング
mapping(df,'latitude','longitude')

timestamp latitude longitude time_diff distance speed acceleration angle diff_angle relative_angle line_number
0 2022-11-25 08:00:00 35.6900 139.6920 300.0 67.325429 0.807905 -4.232274 187.709000 0.000000 -0.000000 0
1 2022-11-25 08:05:00 35.6894 139.6919 300.0 37.934634 0.455216 -4.349820 151.566080 -36.142919 36.142919 1
2 2022-11-25 08:10:00 35.6891 139.6921 4800.0 123.640860 0.092731 0.093011 8.400141 -143.165939 143.165939 2
3 2022-11-25 09:30:00 35.6902 139.6923 300.0 18.062133 0.216746 -2.191704 89.999942 81.599800 -81.599800 3
4 2022-11-25 09:35:00 35.6902 139.6925 8700.0 82.416992 0.034104 0.484834 160.807923 70.807981 -70.807981 4
5 2022-11-25 12:00:00 35.6895 139.6928 300.0 100.482104 1.205785 -14.297818 5.156539 -155.651384 155.651384 5
6 2022-11-25 12:05:00 35.6904 139.6929 8700.0 34.559333 0.014300 0.272849 15.148341 9.991802 -9.991802 5
7 2022-11-25 14:30:00 35.6907 139.6930 300.0 56.140528 0.673686 -7.863380 53.544515 38.396175 -38.396175 6
8 2022-11-25 14:35:00 35.6910 139.6935 8700.0 44.477971 0.018405 0.006610 0.000000 -53.544515 53.544515 7
9 2022-11-25 17:00:00 35.6914 139.6935 3000.0 28.649679 0.034380 0.113453 219.082600 219.082600 39.082600 8
10 2022-11-25 17:50:00 35.6912 139.6933 1200.0 42.974508 0.128924 NaN 320.917564 101.834964 -101.834964 9

以下に、出力されるDataFrameのカラム名とその意味をテーブル形式でまとめました。

カラム名意味
timestampGPSデータの取得時刻。
latitudeGPS地点の緯度(北緯または南緯)。
longitudeGPS地点の経度(東経または西経)。
time_diff現在の行と次の行のタイムスタンプの間の時間差(秒単位)。
distance現在の位置と次の位置との間の距離(メートル単位)。
speed現在の位置と次の位置との間の移動速度(キロメートル毎時、km/h)。
acceleration現在の速度と次の速度との差を時間差で割ったもの(km/h²)。
angleGPSの絶対角度(上:0度、右:90、下:180、左:270)
diff_angle1つ前と現在の角度差
relative_angle常に正面を0度とした場合の相対的な角度(左方向はプラス、右方向はマイナス)
line_number連続するGPS座標のうち、閾値以内の角度であれば1本の直前と見なした場合の連番

この特徴量をさらに2次加工することで、例えば加速度から急発進、急停車の回数を計算したり、旋回回数を計算し、新たな特徴量を導出することも可能になります。

グルーピングにより統計情報を計算する

下記は、DataFrameの指定したカラムでグルーピングし、それぞれに対して統計情報を求める関数です。
平行線の数、曲がった回数、交差する線の数、直線の本数、距離、速度、加速度、角度の最大、最小、平均、分散、標準偏差などの計算結果を返します。

import pandas as pd
import numpy as np

class GPSAnalyzer:
    def __init__(self, df):
        """
        GPSAnalyzerクラスの初期化

        :param df: GPSデータが含まれるDataFrame
        """
        self.df = df

    def haversine(self, lat1, lon1, lat2, lon2):
        """
        Haversine公式を使用して2つのGPS座標間の距離を計算する

        :param lat1: 最初の地点の緯度
        :param lon1: 最初の地点の経度
        :param lat2: 2番目の地点の緯度
        :param lon2: 2番目の地点の経度
        :return: 距離(キロメートル)
        """
        R = 6371  # 地球の半径(km)
        d_lat = np.radians(lat2 - lat1)
        d_lon = np.radians(lon2 - lon1)
        a = np.sin(d_lat / 2) ** 2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(d_lon / 2) ** 2
        c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
        return R * c

    def calculate_angle(self, lat1, lon1, lat2, lon2):
        """
        2点間の角度を計算する

        :param lat1: 最初の地点の緯度
        :param lon1: 最初の地点の経度
        :param lat2: 2番目の地点の緯度
        :param lon2: 2番目の地点の経度
        :return: 角度(度数法)
        """
        d_lon = np.radians(lon2 - lon1)
        lat1 = np.radians(lat1)
        lat2 = np.radians(lat2)
        
        x = np.sin(d_lon) * np.cos(lat2)
        y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(d_lon)
        
        bearing = np.degrees(np.arctan2(x, y))
        return (bearing + 360) % 360


    def do_lines_intersect(self, p1, p2, p3, p4):
        """
        2つの線分が交差するかどうかを判定する

        :param p1: 線分1の始点
        :param p2: 線分1の終点
        :param p3: 線分2の始点
        :param p4: 線分2の終点
        :return: 交差する場合はTrue、そうでない場合はFalse
        """
        def ccw(A, B, C):
            return (C[1] - A[1]) * (B[0] - A[0]) > (B[1] - A[1]) * (C[0] - A[0])
        return ccw(p1, p3, p4) != ccw(p2, p3, p4) and ccw(p1, p2, p3) != ccw(p1, p2, p4)

    def count_intersections(self, gps_data):
        """
        GPSデータから交差点の数をカウントする
        :param gps_data: (latitude, longitude)のタプルのリスト
        :return: 交差点の数
        """
        lines = list(zip(gps_data[:-1], gps_data[1:]))
        intersect_count = 0
        for i in range(len(lines)):
            for j in range(i + 2, len(lines)):  # i+1の線分は隣接するのでスキップ
                if self.do_lines_intersect(lines[i][0], lines[i][1], lines[j][0], lines[j][1]):
                    intersect_count += 1
        return intersect_count


    def calculate_angle_rad(self, lat1, lon1, lat2, lon2):
        """
        2点間の角度をラジアンで計算する

        :param lat1: 最初の地点の緯度
        :param lon1: 最初の地点の経度
        :param lat2: 2番目の地点の緯度
        :param lon2: 2番目の地点の経度
        :return: 角度(ラジアン)
        """
        return np.arctan2(lat2 - lat1, lon2 - lon1)

    def count_parallel_segments(self, angles, tolerance=np.radians(5)):
        """
        平行線の数をカウントする

        :param angles: 各セグメントの角度のリスト
        :param tolerance: 平行と見なす角度の許容範囲(ラジアン)
        :return: 平行線の数
        """
        parallel_count = 0
        n = len(angles)

        for i in range(n):
            for j in range(i + 1, n):
                if np.abs((angles[i] - angles[j] + np.pi) % (2 * np.pi) - np.pi) < tolerance:
                    parallel_count += 1
        return parallel_count

    def count_straight_segments(self, angles, lengths, straight_tolerance=np.radians(5)):
        """
        直線の最大・最小・平均の長さを計算する

        :param angles: 各セグメントの角度のリスト
        :param lengths: 各セグメントの長さのリスト
        :param straight_tolerance: 直線と見なす角度の許容範囲(ラジアン)
        :return: (直線の数, 最大長, 最小長, 平均長)
        """
        if len(angles) == 0 or len(lengths) == 0:
            return 0, 0, 0, 0

        straight_count = 1
        current_length = lengths[0]
        segment_lengths = []

        for i in range(1, len(angles)):
            # 角度差が許容範囲内なら同じ直線のセグメントとして加算
            if np.abs(angles[i] - angles[i - 1]) < straight_tolerance:
                current_length += lengths[i]
            else:
                # 角度差が許容範囲を超えたら直線が切り替わる
                segment_lengths.append(current_length)
                straight_count += 1
                current_length = lengths[i]

        # 最後の直線を追加
        segment_lengths.append(current_length)

        # 最大・最小・平均を計算
        max_length = np.max(segment_lengths)
        min_length = np.min(segment_lengths)
        avg_length = np.mean(segment_lengths)

        return straight_count, max_length, min_length, avg_length

    def count_bent_segments(self, angles, bent_tolerance=np.radians(30)):
        """
        曲がった線の数をカウントする

        :param angles: 各セグメントの角度のリスト
        :param bent_tolerance: 曲がった線と見なす角度の許容範囲(ラジアン)
        :return: 曲がった線の数
        """
        bent_count = 0
        for i in range(1, len(angles)):
            if np.abs(angles[i] - angles[i - 1]) > bent_tolerance:
                bent_count += 1
        return bent_count

    def analyze_gps(self, df,time_col,lat_col,long_col):
        """
        グループ内のGPSデータを分析する
        :param df: GPSデータを格納したDataFrame
        :param time_col: 時刻のカラム名
        :param lat_col: 緯度のカラム名
        :param long_col: 経度のカラム名
        :return: 特徴量を含むDataFrame
        """
        df = df.sort_values(by=time_col)
        df['shifted_latitude'] = df[lat_col].shift(-1)
        df['shifted_longitude'] = df[long_col].shift(-1)
        df['距離'] = df.apply(lambda row: self.haversine(row[lat_col], row[long_col],
                                                               row['shifted_latitude'], row['shifted_longitude']), axis=1)
        df['shifted_timestamp'] = df[time_col].shift(-1)
        df['時間差'] = (df['shifted_timestamp'] - df[time_col]).dt.total_seconds()
        df['速度'] = np.where(df['時間差'] > 0, (df['距離'] / (df['時間差']) * 3600 / 1000), 0)
        df['shifted_speed'] = df['速度'].shift(-1)
        df['加速度'] = np.where(df['時間差'] > 0, (df['shifted_speed'] - df['速度']) / (df['時間差'] / 3600), 0)
        df['角度'] = df.apply(lambda row: self.calculate_angle(row[lat_col], row[long_col],
                                                                      row['shifted_latitude'], row['shifted_longitude']), axis=1)
        move_threshold = 0.01
        df['移動中'] = df['距離'] > move_threshold
        total_move_time = df[df['移動中']]['時間差'].sum()
        total_stop_time = df[~df['移動中']]['時間差'].sum()

        gps_data = list(zip(df[lat_col], df[long_col]))
        intersect_count = self.count_intersections(gps_data)
        angles_rad = [self.calculate_angle_rad(gps_data[i][0], gps_data[i][1], gps_data[i+1][0], gps_data[i+1][1]) for i in range(len(gps_data) - 1)]
        parallel_count = self.count_parallel_segments(angles_rad)
        straight_count,straight_max,straight_min,straight_ave = self.count_straight_segments(angles_rad,df['距離'])
        bent_count = self.count_bent_segments(angles_rad)

        stats = {
            '速度': {
                '平均': df['速度'].mean(),
                '最小': df['速度'].min(),
                '最大': df['速度'].max(),
                '標準偏差': df['速度'].std(),
                '分散': df['速度'].var()
            },
            '距離': {
                '平均': df['距離'].mean(),
                '最小': df['距離'].min(),
                '最大': df['距離'].max(),
                '標準偏差': df['距離'].std(),
                '分散': df['距離'].var()
            },
            '加速度': {
                '平均': df['加速度'].mean(),
                '最小': df['加速度'].min(),
                '最大': df['加速度'].max(),
                '標準偏差': df['加速度'].std(),
                '分散': df['加速度'].var()
            },
            '角度': {
                '平均': df['角度'].mean(),
                '最小': df['角度'].min(),
                '最大': df['角度'].max(),
                '標準偏差': df['角度'].std(),
                '分散': df['角度'].var()
            }
        }

        feature_data = {
            'データ件数': len(df),
            '停止時間合計': total_stop_time,
            '移動時間合計': total_move_time,
            '平行線数': parallel_count,
            '曲がった回数': bent_count,
            '交差線数': intersect_count,
            '直線の数': straight_count,
            '直線の数(最小)': straight_max,
            '直線の数(最大)': straight_min,
            '直線の数(平均)': straight_ave,
            '速度平均': stats['速度']['平均'],
            '速度最小': stats['速度']['最小'],
            '速度最大': stats['速度']['最大'],
            '速度標準偏差': stats['速度']['標準偏差'],
            '速度分散': stats['速度']['分散'],
            '距離平均': stats['距離']['平均'],
            '距離最小': stats['距離']['最小'],
            '距離最大': stats['距離']['最大'],
            '距離標準偏差': stats['距離']['標準偏差'],
            '距離分散': stats['距離']['分散'],
            '加速度平均': stats['加速度']['平均'],
            '加速度最小': stats['加速度']['最小'],
            '加速度最大': stats['加速度']['最大'],
            '加速度標準偏差': stats['加速度']['標準偏差'],
            '加速度分散': stats['加速度']['分散'],
            '角度平均': stats['角度']['平均'],
            '角度最小': stats['角度']['最小'],
            '角度最大': stats['角度']['最大'],
            '角度標準偏差': stats['角度']['標準偏差'],
            '角度分散': stats['角度']['分散']
        }

        return pd.DataFrame([feature_data])

    def analyze(self,group_keys,time_col,lat_col,long_col):
        """
        車両番号と日付でグルーピングし、各グループに対して特徴量を計算する

        :param group_keys: グルーピングのカラムリスト [key1,key2,・・・]        
        :param time_col: 時刻のカラム名
        :param lat_col: 緯度のカラム名
        :param long_col: 経度のカラム名
        :return: 各グループの特徴量を含むDataFrame
        """
        results = self.df.groupby(group_keys, as_index=False, group_keys=False).apply(lambda x:self.analyze_gps(x,time_col,lat_col,long_col),include_groups=False).reset_index(drop=True)
        return results

#========================================================================
# 使用例
#========================================================================
# データをリスト形式で格納
data = [
    ["A01","2024-01-01 08:00:00", 35.689500, 139.692806],
    ["A01","2024-01-01 08:10:00", 35.688641, 139.692806],
    ["A01","2024-01-01 08:25:00", 35.688641, 139.691811],
    ["A01","2024-01-01 08:30:00", 35.689410, 139.691811],
    ["A01","2024-01-01 08:40:00", 35.689410, 139.692695],
    ["A01","2024-01-01 08:50:00", 35.688731, 139.692695],
    ["A01","2024-01-01 09:10:00", 35.688731, 139.691921],
    ["A01","2024-01-01 09:20:00", 35.689319, 139.691921],
    ["A01","2024-01-01 09:35:00", 35.689319, 139.692585],
    ["A01","2024-01-01 09:40:00", 35.688822, 139.692585],
    ["A01","2024-01-01 09:50:00", 35.688370, 139.692585]
]

# DataFrameに変換
df = pd.DataFrame(data, columns=['id','timestamp', 'latitude', 'longitude'])
df['timestamp'] = pd.to_datetime(df['timestamp'])
df['date'] = df['timestamp'].dt.date

analyzer = GPSAnalyzer(df)
result_df = analyzer.analyze(['id', 'date'],"timestamp","latitude","longitude")

print(result_df)


#====================================================================================
# GPS の可視化
#====================================================================================
import folium
from folium import plugins
import webbrowser
import os

def mapping(df,lat_col,long_col,temp_file='gps_track.html'):  
    map = folium.Map(location=[df[lat_col].mean(), df[long_col].mean()],zoom_start=18,
                     tiles='https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
                     attr='世界衛星モザイク画像')
    layer = folium.FeatureGroup()
    polyline = folium.PolyLine(df[[lat_col, long_col]].values, color='blue').add_to(map)
    plugins.PolyLineTextPath(polyline, '   ►   ', repeat=True, attributes={'fill':'#FF0'}).add_to(layer)
    layer.add_to(map)
    map.save(temp_file) 
    webbrowser.open('file://' + os.path.realpath(temp_file))

# GPSの軌跡を地図にマッピング
mapping(df,'latitude','longitude')

データ件数 停止時間合計 移動時間合計 平行線数 曲がった回数 交差線数 直線の数 直線の数(最小) 直線の数(最大) 直線の数(平均) 速度平均 速度最小 速度最大 速度標準偏差 速度分散 距離平均 距離最小 距離最大 距離標準偏差 距離分散 加速度平均 加速度最小 加速度最大 加速度標準偏差 加速度分散 角度平均 角度最小 角度最大 角度標準偏差 角度分散
0 11 0.0 6600.0 9 8 2 9 0.105524 0.059967 0.080778 0.000427 0.0 0.001026 0.000269 7.254979e-08 0.0727 0.05026 0.095516 0.015162 0.00023 -0.001056 -0.006565 0.002667 0.002605 0.000007 144.000006 0.0 270.00029 96.747196 9360.019867

以下に、出力されるDataFrameのカラム名とその意味をテーブル形式でまとめました。

カラム名意味
データ件数GPSデータの総件数
停止時間合計停止している時間の合計(秒単位)
移動時間合計移動している時間の合計(秒単位)
平行線数平行している線の数(移動経路における平行な線の数)
曲がった回数曲がった地点の数(曲線を描いた場所の数)
交差線数交差した線の数(移動経路で交差する線の数)
直線の数直線で移動している地点の数(直線部分の数)
直線の数(最小)直線部分の最小長さ
直線の数(最大)直線部分の最大長さ
直線の数(平均)直線部分の平均長さ
速度平均移動中の平均速度(キロメートル/時)
速度最小最小速度(キロメートル/時)
速度最大最大速度(キロメートル/時)
速度標準偏差速度の標準偏差
速度分散速度の分散
距離平均移動した距離の平均(キロメートル)
距離最小最小距離(キロメートル)
距離最大最大距離(キロメートル)
距離標準偏差距離の標準偏差
距離分散距離の分散
加速度平均移動中の平均加速度(キロメートル/時²)
加速度最小最小加速度(キロメートル/時²)
加速度最大最大加速度(キロメートル/時²)
加速度標準偏差加速度の標準偏差
加速度分散加速度の分散
角度平均進行方向の角度の平均(度数法)
角度最小最小角度(度数法)
角度最大最大角度(度数法)
角度標準偏差角度の標準偏差
角度分散角度の分散

GPS軌跡を簡単に描画できるGpsPlotクラス

DataFrameの緯度、経度のカラムを指定することで、地図上に移動軌跡やマーカー、サークルが簡単に描画できる自作クラスです。

GpsPlotのリファレンス

メソッド名説明引数戻り値
__init__(tiles=0)クラスの初期化。地図のタイルの種類を設定します。tiles:
タイルの種類
0: openstreetmap,
1: 全国最新写真
なし
read_gps_file(
file_name
)
GPSデータをファイルから読み込み、リストとして返します。file_name:
GPSデータが格納されたファイル名
GPSデータのリスト(タプル)
read_dataframe(
df,
lat_col, lon_col
)
DataFrameからGPSデータを読み込みます。df:
GPSデータが格納されたDataFrame
lat_col:
緯度データのカラム名
lon_col:
経度データのカラム名
GPSデータのリスト(タプル)
calculate_center_and_zoom(
gps_data
)
GPSデータから中心座標とズームレベルを計算します。gps_data:
(latitude, longitude) のタプルのリスト
(center_lat, center_lon, zoom_level)
plot_gps(
gps_data=None
)
GPSデータを地図にプロットします。gps_data:
(latitude, longitude) のタプルのリスト
省略時はインスタンス内のデータを使用
なし
plot_marker(
locations,
color="red"
)
地図にマーカーを追加します。locations:
{"name":
(latitude, longitude)} の辞書
color:
マーカーの色
なし
plot_circle(
location,
radius=80,
color="blue",
fill_color="#ffff00"
)
地図にサークルマーカーを追加します。location:
(latitude, longitude) のタプル
radius:
サークルの半径
color:
サークルの色
fill_color:
塗りつぶし色
なし
show(
filename=None
)
地図を表示します。オプションでファイル名を指定して保存することもできます。filename:
保存するファイル名指初期値は 'map.html'
なし
save(
filename
)
地図を指定したファイル名で保存します。filename:
保存するファイル名
なし

GpsPlotのソースコード

# pip install folium
# pip install celenium
# https://storage.googleapis.com/chrome-for-testing-public/128.0.6613.119/win64/chromedriver-win64.zip
import time
import folium
from folium import plugins
import webbrowser
import pandas as pd

class GpsPlot:
    def __init__(self,tiles = 0):
        """
        GpsPlotクラスの初期化
        :param tiles: タイルの種類(0: openstreetmap, 1: 全国最新写真(シームレス))
        """   
        self.gps_data = []
        self.map = None
        if tiles == 1:
            self.tiles = "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg"
            self.attr = "全国最新写真(シームレス)"
        else:
           self.tiles = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
           self.attr = "openstreetmap"

    def read_gps_file(self,file_name):
        """
        GPSデータをファイルから読み込む
        :param file_name: GPSデータが格納されたファイルの名前
        :return: (latitude, longitude)のタプルのリスト
        """
        gps_data = []
        with open(file_name, 'r') as file:
            for line in file:
                # 行をトリムして、カンマで分割
                lat, lon = map(float, line.strip().split(','))
                gps_data.append((lat, lon))
            self.gps_data = gps_data
        return gps_data

    def read_dataframe(self,df, lat_col, lon_col):
        """
        GPSデータをDataFrameから読み込む

        :param df: GPSデータが格納されたDataFrame
        :param lat_col: 緯度データを含むカラムの名前
        :param lon_col: 経度データを含むカラムの名前
        :return: (latitude, longitude)のタプルのリスト
        """
        self.gps_data = list(df[[lat_col, lon_col]].itertuples(index=False, name=None))
        return self.gps_data
    
    def calculate_center_and_zoom(self,gps_data):
        """
        中心座標とズームレベルを計算する
        :param gps_data: (latitude, longitude)のタプルのリスト
        :return: (中心緯度, 中心経度, ズームレベル)
        """
        # 緯度と経度のリストに分解
        latitudes = [lat for lat, lon in gps_data]
        longitudes = [lon for lat, lon in gps_data]

        # 中心座標を計算
        center_lat = sum(latitudes) / len(latitudes)
        center_lon = sum(longitudes) / len(longitudes)
        
        # 緯度と経度の範囲を計算
        lat_range = (min(latitudes), max(latitudes))
        lon_range = (min(longitudes), max(longitudes))

        # 緯度と経度の範囲からズームレベルを計算
        lat_diff = lat_range[1] - lat_range[0]
        lon_diff = lon_range[1] - lon_range[0]

        # ズームレベルの計算(適宜調整が必要)
        zoom_level = 14  # デフォルトのズームレベル
        if lat_diff > 5 or lon_diff > 5:
            zoom_level = 10  # 広範囲の場合
        elif lat_diff > 1 or lon_diff > 1:
            zoom_level = 12  # 中程度の範囲

        return center_lat,center_lon,zoom_level

    def plot_gps(self,gps_data = None):
        """
        GPSデータを地図にプロットする
        :param gps_data: (latitude, longitude)のタプルのリスト
        """
        if gps_data is None:
            gps_data = self.gps_data

        # 無効なGPSデータを除外する
        gps_data = [coord for coord in gps_data if coord != (0.0, 0.0)]

        # ズームレベルを計算
        center_lat,center_lon,zoom_start = self.calculate_center_and_zoom(gps_data)

        # 地図の初期化
        self.map = folium.Map(location=[center_lat, center_lon], zoom_start=zoom_start,
                        tiles=self.tiles,attr=self.attr)
        layer = folium.FeatureGroup()

        # ポリラインを地図に追加
        polyline = folium.PolyLine(gps_data, popup='GPS Data', color="blue", weight=3).add_to(layer)
        polyline_text = plugins.PolyLineTextPath(polyline, '   ►   ', repeat=True, attributes={'fill':'#FF0'}).add_to(layer)

        layer.add_to(self.map)
        self.map.fit_bounds(layer.get_bounds())

    def plot_marker(self,locations,color="red"):
        """
        地図にマーカーを追加する
        :param locations: {"name": (latitude, longitude)} の辞書
        :param color: マーカーの色
        """
        for name,location in locations.items():
            folium.Marker(
                location=location,
                
                popup=name,
                icon=folium.Icon(color=color, icon="info-sign")
                ).add_to(self.map)

    def plot_circle(self,location,radius=80,color="bule",fill_color="#ffff00"):
        """
        地図にサークルマーカーを追加する
        :param location: (latitude, longitude) のタプル
        :param radius: サークルの半径
        :param color: サークルの色
        :param fill_color: サークルの塗りつぶし色
        """
        folium.CircleMarker(
        location=location,
        radius=radius,
        color=color, #カラーコード
        fill_color=fill_color #カラーコード
        ).add_to(self.map)

    def show(self,filename = None):
        """
        地図を表示する
        :param filename: 保存するファイル名(指定しない場合は 'map.html')
        """
        if filename is None:
            self.map.save('map.html')
            webbrowser.open('map.html')
        else:
            webbrowser.open(filename)

    def save(self,filename):
        """
        地図を保存する
        :param filename: 保存するファイル名
        """
        self.map.save(filename)

import time
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

def save_screenshot(html, image_file):
    # ChromeDriverのパスを指定してブラウザオプションを設定
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')  # ヘッドレスモードで実行
    options.add_argument('--disable-gpu')  # Windowsでの描画バグを避けるため
    options.add_argument('--no-sandbox')  # サンドボックスを無効化(Linux用)
    
    # WebDriverの設定
    service = Service(ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=options)
    
    # 指定されたHTMLファイルを開く
    driver.get('file://' + html)
    
    # ページが完全に読み込まれるのを待つ
    time.sleep(2)
    
    # スクリーンショットを保存
    driver.save_screenshot(image_file)
    
    # ブラウザを終了
    driver.quit()


#====================================================================================
# 使用例
#====================================================================================
data = {
    'timestamp': ['2022-11-25 08:00', '2022-11-25 08:05', '2022-11-25 08:10',
                  '2022-11-25 09:30', '2022-11-25 09:35', '2022-11-25 12:00',
                  '2022-11-25 12:05', '2022-11-25 14:30', '2022-11-25 14:35', '2022-11-25 17:00'],
    'latitude': [35.6899, 35.6895, 35.6898, 35.6902, 35.6904, 35.6902, 35.6906, 35.6909, 35.6910, 35.6913],
    'longitude': [139.6917, 139.6919, 139.6921, 139.6923, 139.6925, 139.6927, 139.6929, 139.6931, 139.6933, 139.6935]
}
df = pd.DataFrame(data)
df['timestamp'] = pd.to_datetime(df['timestamp'])


gp = GpsPlot(0)
gp.read_dataframe(df,'latitude','longitude')
gp.plot_gps()
gp.plot_marker({'開始':(35.6899, 139.6917),'終了':(35.6913,  139.6935)})
gp.plot_circle([35.6899, 139.6917],color='red',fill_color='red')
gp.plot_circle([35.6913,  139.6935],color='blue',fill_color='blue')
gp.show()

#gp.save("p:/gps.html")
#save_screenshot("p:/gps.html",'p:/map.png')

まとめ

本記事では、Foliumを用いたGPS位置情報の可視化と、GPSから様々な特徴量を算出する方法について解説しました。
また、それぞれの章において自作関数や自作クラスのソースコードを掲載し、コピペですぐに使えるように配慮しました。

GPSからの特徴量抽出は、何を分析するかによって異なりますが、今回のアプローチがヒントになると思います。
もし、GPSの情報から、フォークリフトやトラックなどの車両の移動ルートや使われ方を分類する必要が生じたら、是非この記事を参考にしてください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次