Googleロケーション履歴を元に写真に位置情報を付与する

## 目的
Googleのロケーション履歴（タイムライン）を元にJPEGファイルに「だいたい」[^5]の位置情報を付与するプログラムを作成しました(→[AddGglLoc on Github](https://github.com/tatesuke/AddGglLoc))。 
作成過程で調べた情報を記述していこうと思います。

※ **このプログラムを利用して大切なファイルを破壊したとしても責任は負いません。**
※ 誤った記述を見つけたら是非コメントをください

[^5]: GPSログとのマッチングのように正確な位置情報ではありません。

## 動機
今どきのスマホや高機能なデジカメで写真を撮ると、その時の位置情報がファイルに埋め込まれます。画像閲覧ソフトによっては画像と一緒に地図も表示され様々な思い出が蘇ってくるのでとても好きです。

例えば、以下は姫路城で撮影した写真なのですが、右側に地図が表示されているのが分かると思います。
![位置情報.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29682/e4e8c65c-c902-163a-a096-b0cadbd42588.png)

私はできるだけ写真に位置情報を埋め込みたいと思っているのですが、私の使用しているデジカメにはGPS機能がないため、別途GPSロガーで取得した情報とマッチングして後から位置情報を付与する必要があります。しかし、常にGPSロガーを起動しているのは億劫だったり、GPSロガーを起動し忘れたりして位置情報を付与できないこと多々あり、歯痒い思いをしていました。

ところで、位置情報といえば、私はスマホのGoogleMapで「ロケーション履歴」を有効にしているので、スマホを持ち歩いているだけで勝手に自分の居場所が記録されていきます。いわゆる「タイムライン」ってやつですね。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29682/0329d240-e7ab-29a3-e55d-ec7a0afefb53.png" width="40%"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29682/157f9dd5-f937-c673-5ec1-4cd657db76af.png" width="40%">


このロケーション履歴はGoogleのWebアプリ、スマホアプリ上でしか見れないと思っていたのですが、以下のURLからJSON型式でダウンロードできるということを知りました[^111]。

[^111]: 正確には後々DL用のリンクが送られてくる。

https://takeout.google.com/settings/takeout  ![ロケーション履歴のエクスポート.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29682/82f02ed9-f175-8be0-e981-67b722d2e7ae.png)

この情報を使えば写真に位置情報を付与することができそうじゃないですか！

ということで、作ります。


## ロケーション履歴ファイルの調査

まず、JSONファイルにはどのような情報が記録されているのか知らなければなりません。調査してわかった内容をメモしておきます。

### ファイル構成
上記URLからダウンロードしたZIPファイルを展開すると、次のようなディレクトリ構成となっています。年単位でディレクトリが切られていて、その下に月ごとのロケーション履歴がJSONファイルとして格納されているようです。

```text
.
├─2018
│      2021_APRIL.json
│      2021_AUGUST.json
│      2021_DECEMBER.json
│      ・・・
│
├─2019
│      ・・・
│
├─2020
│      ・・・
│
├─2021
│      ・・・
│
└─2022
        2022_JANUARY.json
        ※ この調査をしたときは2022年1月だったので1月までのファイルが出ています
```

### JSON構成
JSONファイルを覗いていると、すべて同じ型式のJSONファイルでした。次のように、`"timelineObjects"`という配列の中に`"activitySegment"`というオブジェクトと`"placeVisit"'というオブジェクトが交互に格納されているようです。

```json
{
  "timelineObjects": [
    {
      "activitySegment": {(略)}
    },
    {
      "placeVisit": {(略)}
    },
    (略)
  ]
}
```

#### 'activitySegment'(移動)
`activitySegment`は「移動」の情報を格納しています。移動手段など様々な情報が格納されているのですが、今回の目的に合いそうな部分を抜粋して以下に示します。

```json
{
  "activitySegment": {
    "startLocation": {
      "latitudeE7": 355050042,
      "longitudeE7": 1387341570,
    },
    "endLocation": {
      "latitudeE7": 354423067,
      "longitudeE7": 1386030378,
    },
    "duration": {
      "startTimestamp": "2018-04-16T03:22:42.995Z",
      "endTimestamp": "2018-04-16T05:19:38.001Z"
    },
    (略)
    "waypointPath": {
      "waypoints": [
        {
          "latE7": 355049934,
          "lngE7": 1387342071
        },
        (略)
        {
          "latE7": 354431419,
          "lngE7": 1386032867
        }
      ],
    },
    "simplifiedRawPath": {
      "points": [
        {
          "latE7": 355081374,
          "lngE7": 1387612949,
          "accuracyMeters": 10,
          "timestamp": "2018-04-16T03:32:38.003Z"
        },
        (略)
        {
          "latE7": 354743523,
          "lngE7": 1385758080,
          "accuracyMeters": 5,
          "timestamp": "2018-04-16T05:09:10.498Z"
        }
      ]
    },
    (略)
  }
}
```

| 項目 | 説明 | メモ |
|--|--|--|
| `startLocation` | 出発地の座標 | |
| `endLocation` | 到着地の座標 |  |
| `duration` | 出発時間と到着時間 | |
| `waypointPath` | 移動中の座標の配列 | 時間の情報が格納されておらず、残念ながら位置情報付与には使えなさそう |
| `simplifiedRawPath` | 簡略化した（？）移動中の座標の配列2 | 時間の情報が格納されているため、位置情報付与に使えそう。<br>`simplified`という名前の通り、何かしらの手が加えられているようで`waypointPath`よりかなりポイントが少ないのが残念。　|

#### `placeVisit` (訪れた場所)
`placeVisit`は訪れた場所の情報を格納しています。今回の目的に合いそうな部分を抜粋して以下に示します。

```json
{
  "placeVisit": {
    "location": {
      "latitudeE7": 354423067,
      "longitudeE7": 1386030378,
      "placeId": "ChIJ3fdiwnbnG2ARgw16pm1gZ50",
      "address": "日本、〒401-0337 山梨県南都留郡富士河口湖町本栖２１２",
      "name": "Fuji Motosuko Resort",
      (略)
    },
    "duration": {
      "startTimestamp": "2018-04-16T05:19:38.001Z",
      "endTimestamp": "2018-04-16T05:35:21.010Z"
    },
     (略)
  }
}
```

| 項目 | 説明 | メモ |
|--|--|--|
| `location` | 訪れた場所の情報 | `placeId`というのは多分Google側で付与しているID | 
| 'duration' | 到着時間と、出発時間 |  |

他にも「場所の候補」などが入っていたので、GoogleMapのタイムライン上である日の情報を確定しておく[^456]かどうかで精度が変わってきそう。

[^456]: 「〇〇を訪問しましたか？　はい、いいえ」みたいなあれ

## プログラム
Googleロケーション履歴ファイルの構造がわかったので、これを元に写真に位置情報を付与するプログラムを作りました。

言語はPython3、JPEGのExifを読み書きするのに[Piexif](https://piexif.readthedocs.io/en/latest/)を利用しました。

全ソース、使い方は[Github](https://github.com/tatesuke/AddGglLoc)に配置しますが、ここでは主要部分をピックアップして処理を説明します。

### 大枠
細かな制御を除くと以下のような流れになっています(ソースでは[このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/addgglloc.py#L50))。

* Googleロケーション履歴ファイルを読み込み
* 全JPEGファイルを列挙
* JPEGファイルを1ファイルずつ処理
    * 既に位置情報が付与されていたらスキップ
    * JPEGのタイムスタンプとGoogleロケーション履歴の時間で位置情報をマッチング
        * 位置情報がマッチングできなければスキップ
        * 位置情報がマッチングできれば位置情報を書き込み

### Googleロケーション履歴ファイルを読み込み

Googleロケーション履歴ファイルの読み込みは[このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/google_location_log_loader.py#L19)で行っています。

```python
with open(fileName, "r", encoding="utf-8") as f:
    try:
        file = json.load(f)
    except json.decoder.JSONDecodeError as e:
        raise InvalidFileFormatException("Json parse error.") from e
(略)
locationLogs = []
for timelineObject in file["timelineObjects"]:
    if "activitySegment" in timelineObject:
        locationLogs.extend(
            cls._processActivtySegment(
                timelineObject["activitySegment"])
        )
    elif "placeVisit" in timelineObject:
        locationLogs.extend(
            cls._processPlaceVisit(timelineObject["placeVisit"])
        )
```

JSONファイルを読み込み、「時間」、「座標」、「その場所の名前」を保持する[`LocationLog`オブジェクト](https://github.com/tatesuke/AddGglLoc/blob/main/addgglloc/location_log.py#L11)の配列を生成します[^1]。

[^1]: メモリに乗らないくらいのデータ量になったら、単純に配列に乗せるだけというわけにはいかないかもしれないです。4年＋数ヶ月分くらいは乗りました。

####  `activitySegment`の読み込み

`activitySegment`（移動）の読み込みは[このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/google_location_log_loader.py#L62)で行っています。

```python
# 出発地点の情報
if "latitudeE7" in activtySegment["startLocation"]:
    locationLogs.append(LocationLog(**{
        "timestamp": cls._convertTimestamp(activtySegment["duration"]["startTimestamp"]),
        "lat": cls._e7ToDegree(activtySegment["startLocation"]["latitudeE7"]),
        "lon": cls._e7ToDegree(activtySegment["startLocation"]["longitudeE7"]),
    }))
# 到着地点の情報
if "latitudeE7" in activtySegment["endLocation"]:
    locationLogs.append(LocationLog(**{
        "timestamp": cls._convertTimestamp(activtySegment["duration"]["endTimestamp"]),
        "lat": cls._e7ToDegree(activtySegment["endLocation"]["latitudeE7"]),
        "lon": cls._e7ToDegree(activtySegment["endLocation"]["longitudeE7"]),
    }))

# 経路の情報
if "simplifiedRawPath" not in activtySegment:
    return locationLogs

for p in activtySegment["simplifiedRawPath"]["points"]:
    locationLogs.append(LocationLog(**{
        "timestamp": cls._convertTimestamp(p["timestamp"]),
        "lat": cls._e7ToDegree(p["latE7"]),
        "lon": cls._e7ToDegree(p["lngE7"]),
    }))
```

出発地、道のり、到着地から情報を抜き出しているだけで、特に捻ったことは行っていません[^2]。

[^2]: 文字列からdatetimeへのフォーマット変更などは行っています。

### `placeVisit`の読み込み

`placeVisit`（訪れた場所）の読み取りは[このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/google_location_log_loader.py#L130)で行っています。

```python
# 到着時間のログを作成
current = start
locationLogs.append(LocationLog(**{
    "timestamp": start,
    "lat": lat,
    "lon": lon,
    "areaInformation": name
}))

# 到着から出発までの間のログを作成
# TODO 5分は可変にしたほうがいいんだろうな
while (end - current).total_seconds() > (5 * 60):
    current = current + datetime.timedelta(minutes=5)
    locationLogs.append(LocationLog(**{
        "timestamp": current,
        "lat": lat,
        "lon": lon,
        "areaInformation": name
    }))

# 出発時間のログを作成
locationLogs.append(LocationLog(**{
    "timestamp": end,
    "lat": lat,
    "lon": lon,
    "areaInformation": name
}))
```

到着時刻から出発時刻までの位置情報を生成しているのですが、ロケーション履歴には「到着」と「出発」の情報しかありません。「滞在中」の位置情報も写真に付与したいので、「到着」と「出発」の間に5分間隔で同じ位置情報を出力するようにしています[^1238]（`while (end - current).total_seconds() > (5 * 60)`のあたり）。

[^1238]: 5分というのは適当に定めた時間なので、本当は可変にすべきなのでしょう・・・

#### ソート

上記の処理で得られた位置情報の配列は、最後にタイムスタンプをキーにソートしておきます([このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/addgglloc.py#L120))。JPEGファイルの撮影時間とマッチングするときに二分探索を行うためです。

```python
# 2部探索するためにソートしておく
locationLogs.sort(key=lambda l: l.timestamp)
```


### JPEGファイルの読み込み

まず、対象ディレクトリ配下のJPEGファイルをすべて検索して、パスを配列に格納します([このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/addgglloc.py#L86))。

```python
def isJpeg(filePath: str) -> bool:
    extention = filePath.split(".")[-1].lower()
    return (extention == "jpg") or (extention == "jpeg")
jpegFiles = [
    os.path.join(curDir, file)
    for curDir, _, files in os.walk(baseDir)
    for file in files
    if isJpeg(file)
]
logger.info(f"[END]\t{len(jpegFiles)}個のJPEGファイルが見つかりました。")
```

検索しつつ位置情報付与してもいいのですが、数が多くて時間がかかるときに全量がわからないと不安になるので先に全量を読み込んでいます。

### JPEGファイルへ位置情報を付与

JPEGに対する位置情報付与は[このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/addgglloc.py#L150)で行っています。

```python
# ①Exifを辞書として読み込む
exifDict: Dict[str, Any] = cast(Dict[str, Any], piexif.load(f"{file}"))

# ②既にGPS情報が格納されていたら終了
if self._hasLocationLog(exifDict):
    return FileProcessResult("SKIP", successMsg="ロケーション情報がすでに存在します。")

# ③タイムスタンプに紐づく位置情報を取得
shootingDateTime = self._getShootingDate(exifDict)
locationLog = self._matchLocationLog(locationLogs, shootingDateTime)

# ④位置情報が取得できなければ終了
if locationLog is None:
    return FileProcessResult("SKIP", successMsg="どのロケージョン履歴ともマッチしませんでした。")

# ⑤位置情報を辞書に書き込む
exifDict = locationLog.writeTo(exifDict)

（略）

# 位置情報付与して出力
exifBytes = piexif.dump(exifDict)
piexif.insert(exifBytes, file, outputPath)

（略）
```

① `Piexif`でJPEGのEXIF情報を読み込んでいます。
② 既に位置情報が存在すればそのファイルはスキップします。
③ JPEGファイルの撮影時間でGoogleロケーション履歴とマッチングします。
④ マッチするロケーション履歴がなければそのファイルはスキップします。
⑤ マッチするロケーション履歴があればその情報を`Piexif`で付与します。

マッチングの処理は以下のように実装しました([このあたり](https://github.com/tatesuke/AddGglLoc/blob/613b151ebea01eec65e0633434b35e1154c0b165/addgglloc/addgglloc.py#L211))。

```python
def _matchLocationLog(self, locationLogs: List[LocationLog], shootingDateTime: Optional[datetime]) -> Optional[LocationLog]:
    """ 撮影時間における位置情報を返す。推測できなければNoneを返す。 """

    if shootingDateTime is None:
        return None

    # 位置情報を2分探索
    left = 0
    right = len(locationLogs) - 1
    center = 0
    while (left <= right):
        center = left + int((right - left) / 2)
        centerDateTime = locationLogs[center].timestamp
        if centerDateTime == shootingDateTime:
            break
        if shootingDateTime > centerDateTime:
            left = center + 1
        else:
            right = center - 1

    # 最終的にたどり着いた位置情報と撮影時間とだいたい同じであればその位置情報を採用
    delta = locationLogs[center].timestamp - shootingDateTime
    if delta.total_seconds() <= self.toleranceSec:
        return locationLogs[center]

    return None
```

JPEGの撮影日時でGoogleロケーション履歴を二部探索しています。JPEGの撮影日時と、二部探索で最終的にたどり着いた位置情報の日時がぴったり一致することはまずないので、前後数分の範囲内であればその位置情報を採用するようにしています。

## 制約とかやりたいこととか(ぼやき）

* Googleロケーション履歴自体がある程度要約された情報なので、付与される位置情報も「だいたい」の精度となる 
    * また、「移動」の道のりがかなり削られてれてしまっているので、移動しながらとった写真とはマッチしにくい
* デジカメの時計のずれなんかも考慮すべきかもしれない
* GPSロガーのログ（例えば`NMEA`や`GPX`)も取り扱えるときは、まずGPSロガーで正確な情報を取れるか試みて、だめだったときにGoogleの履歴を使うようにすればよい
* 定期的に自動で履歴をダウンロードして勝手に位置情報付与とかやってくれたら楽だなあ