1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AISを使って世界の船の位置をマッピングしてみた。

1
Last updated at Posted at 2026-03-29

はじめに

 AIS(Automatic Identification System)は、船舶が自分の位置・速度・航行情報を送信する国際標準システム。VHF帯の無線通信を使って、船舶同士・陸上局・衛星に向けてデータを送信することで、世界中の船の位置がわかる。
 本記事では、WebSocket で AIS データを配信してくれる aisstream.io を利用して、
Pythonコードでリアルタイムの船舶位置を 地図上にプロット してみる。

目次

aisstream.ioとは

aisstreamioは無料のWebSocket APIで、GitHubアカウントでAPIキーを取得できる。
aisstream.io が提供する AIS データは、陸上AIS局で受信された信号をリアルタイム配信している。衛星AIS局のデータはカバーしていないため、沿岸部の船舶データが中心となる。外洋の船舶まではカバーしていない点注意。
戻る

AISデータ取得のサンプルコード

aisstream.ioのAPIを使って取得したデータを整理し、csvファイルに出力するサンプルコード。ポイントをまとめてみた。詳細はAPIのドキュメント参照。

  • APIで取得する情報を指定する
    FilterMessageTypesで取得するメッセージを指定する。
    BoundingBoxesで範囲を指定。[[南西の緯度, 南西の経度], [北東の緯度, 北東の経度]]例えば下記のように指定する。
    • 東京湾   :[[35.0, 139.6], [35.7, 140.1]]
    • 日本    :[[24.0, 122.0], [46.0, 154.0]]
    • ホルムズ海峡:[[25.0, 55.0], [27.5, 57.0]]
    • 世界    :[[-90.0, -180.0], [90.0, 180.0]]
subscribe_msg = {
	'APIKey': key,
	'BoundingBoxes': [range],
	'FilterMessageTypes': ['PositionReport','ShipStaticData','StaticDataReport']
}

戻る
 

	StatusDic = {
		0:  '機関使用中で航行', 1:  '錨泊中', 2:  '操船不能', 3:  '操船制限', 4:  '喫水制限', 5:  '係留中', 6:  '座礁中', 7:  '漁労中', 8:  '帆走中',
		9:  '予約', 10: '予約', 11: '予約', 12: '予約', 13: '予約',
		14: '遭難信号', 15: '未定義'
	}

戻る
 

  Coutrydic= {
      201: "Albania", 202: "Andorra", 203: "Austria", 204: "Portugal - Azores",
      205: "Belgium", 206: "Belarus", 207: "Bulgaria", 208: "Vatican City State",
      .....
      745: "France - Guiana", 750: "Guyana",
      755: "Paraguay", 760: "Peru", 765: "Suriname",
      770: "Uruguay", 775: "Venezuela",
  }

戻る
 

  • サンプルコードの関数
関数名 処理内容
get_country_mmsi(mmsi) 船籍取得
output_PositionReport(msg) PositionReportから船の位置や状態を取得
output_ShipStaticData(msg) ShipStaticDataから船の情報を取得
output_StaticDataReport(msg) StaticDataReport船のタイプを取得
get_ais(url,key,range,q) AISを取得する処理。Task1
recoreds_ais(q) データrecoreds[]に詰めていく。同じMMSIは上書きする。Task2
save_to_csv(sec) sec秒ごとにcsvファイルに保存する。Task3
main() メイン関数

戻る
 

  • サンプルコード
ais_sample.py
import asyncio
import websockets
import pandas as pd
import json
import os
from datetime import datetime, timezone

url = 'wss://stream.aisstream.io/v0/stream'
key = '取得したAPIキーを設定する'
range = [[35.0, 139.6], [35.7, 140.1]]  # 東京湾
#range = [[-90.0, -180.0], [90.0, 180.0]] # 世界

def get_country_mmsi(mmsi):
	Coutrydic= {
		201: "Albania", 202: "Andorra", 203: "Austria", 204: "Portugal - Azores",
		205: "Belgium", 206: "Belarus", 207: "Bulgaria", 208: "Vatican City State",
		209: "Cyprus", 210: "Cyprus", 211: "Germany", 212: "Cyprus",
		213: "Georgia", 214: "Moldova", 215: "Malta", 216: "Armenia",
		218: "Germany", 219: "Denmark", 220: "Denmark", 224: "Spain",

		225: "Spain", 226: "France", 227: "France", 228: "France",
		229: "Malta", 230: "Finland", 231: "Denmark - Faroe Islands",
		232: "United Kingdom - Great Britain and Northern Ireland",
		233: "United Kingdom - Great Britain and Northern Ireland",
		234: "United Kingdom - Great Britain and Northern Ireland",
		235: "United Kingdom - Great Britain and Northern Ireland",
		236: "United Kingdom - Gibraltar", 237: "Greece", 238: "Croatia",
		239: "Greece", 240: "Greece", 241: "Greece", 242: "Morocco",
		243: "Hungary", 244: "Netherlands",

		245: "Netherlands", 246: "Netherlands", 247: "Italy", 248: "Malta",
		249: "Malta", 250: "Ireland", 251: "Iceland", 252: "Liechtenstein",
		253: "Luxembourg", 254: "Monaco", 255: "Portugal - Madeira",
		256: "Malta", 257: "Norway", 258: "Norway", 259: "Norway",
		261: "Poland", 262: "Montenegro", 263: "Portugal", 264: "Romania",
		265: "Sweden",

		266: "Sweden", 267: "Slovak Republic", 268: "San Marino",
		269: "Switzerland", 270: "Czech Republic", 271: "Türkiye",
		272: "Ukraine", 273: "Russian Federation", 274: "North Macedonia",
		275: "Latvia", 276: "Estonia", 277: "Lithuania", 278: "Slovenia",
		279: "Serbia", 301: "United Kingdom - Anguilla",
		303: "United States - Alaska", 304: "Antigua and Barbuda",
		305: "Antigua and Barbuda", 306: "Netherlands", 307: "Netherlands",

		308: "Bahamas", 309: "Bahamas", 310: "United Kingdom - Bermuda",
		311: "Bahamas", 312: "Belize", 314: "Barbados", 316: "Canada",
		319: "United Kingdom - Cayman Islands", 321: "Costa Rica",
		323: "Cuba", 325: "Dominica", 327: "Dominican Republic",
		329: "France - Guadeloupe", 330: "Grenada",
		331: "Denmark - Greenland", 332: "Guatemala", 334: "Honduras",
		336: "Haiti", 338: "United States of America", 339: "Jamaica",

		341: "Saint Kitts and Nevis", 343: "Saint Lucia", 345: "Mexico",
		347: "France - Martinique", 348: "United Kingdom - Montserrat",
		350: "Nicaragua", 351: "Panama", 352: "Panama", 353: "Panama",
		354: "Panama", 355: "Panama", 356: "Panama", 357: "Panama",
		358: "United States - Puerto Rico", 359: "El Salvador",
		361: "France - Saint Pierre and Miquelon",
		362: "Trinidad and Tobago", 364: "United Kingdom - Turks and Caicos Islands",
		366: "United States of America", 367: "United States of America",
		368: "United States of America",

		369: "United States of America", 370: "Panama", 371: "Panama",
		372: "Panama", 373: "Panama", 374: "Panama",
		375: "Saint Vincent and the Grenadines",
		376: "Saint Vincent and the Grenadines",
		377: "Saint Vincent and the Grenadines",
		378: "United Kingdom - British Virgin Islands",
		379: "United States - United States Virgin Islands",
		401: "Afghanistan", 403: "Saudi Arabia", 405: "Bangladesh",
		408: "Bahrain", 410: "Bhutan", 412: "China", 413: "China",
		414: "China", 416: "China",

		417: "Sri Lanka", 419: "India", 422: "Iran", 423: "Azerbaijan",
		425: "Iraq", 428: "Israel", 431: "Japan", 432: "Japan",
		434: "Turkmenistan", 436: "Kazakhstan", 437: "Uzbekistan",
		438: "Jordan", 440: "Korea", 441: "Korea",
		443: "State of Palestine", 445: "North Korea", 447: "Kuwait",
		450: "Lebanon", 451: "Kyrgyz Republic", 453: "China",

		455: "Maldives", 457: "Mongolia", 459: "Nepal", 461: "Oman",
		463: "Pakistan", 466: "Qatar", 468: "Syria",
		470: "United Arab Emirates", 471: "United Arab Emirates",
		472: "Tajikistan", 473: "Yemen", 475: "Yemen",
		477: "China", 478: "Bosnia and Herzegovina",
		501: "France - Adelie Land", 503: "Australia",
		506: "Myanmar", 508: "Brunei Darussalam", 510: "Micronesia",
		511: "Palau",

		512: "New Zealand", 514: "Cambodia", 515: "Cambodia",
		516: "Australia - Christmas Island",
		518: "New Zealand - Cook Islands", 520: "Fiji",
		523: "Australia - Cocos", 525: "Indonesia", 529: "Kiribati",
		531: "Laos", 533: "Malaysia",
		536: "United States - Northern Mariana Islands",
		538: "Marshall Islands", 540: "France - New Caledonia",
		542: "New Zealand - Niue", 544: "Nauru",
		546: "France - French Polynesia", 548: "Philippines",
		550: "Timor-Leste", 553: "Papua New Guinea",

		555: "United Kingdom - Pitcairn Island",
		557: "Solomon Islands", 559: "United States - American Samoa",
		561: "Samoa", 563: "Singapore", 564: "Singapore",
		565: "Singapore", 566: "Singapore", 567: "Thailand",
		570: "Tonga", 572: "Tuvalu", 574: "Viet Nam",
		576: "Vanuatu", 577: "Vanuatu",
		578: "France - Wallis and Futuna Islands",
		601: "South Africa", 603: "Angola", 605: "Algeria",
		607: "France - Saint Paul and Amsterdam Islands",
		608: "United Kingdom - Ascension Island",

		609: "Burundi", 610: "Benin", 611: "Botswana",
		612: "Central African Republic", 613: "Cameroon",
		615: "Congo", 616: "Comoros", 617: "Cabo Verde",
		618: "France - Crozet Archipelago", 619: "Côte d'Ivoire",
		620: "Comoros", 621: "Djibouti", 622: "Egypt",
		624: "Ethiopia", 625: "Eritrea", 626: "Gabon",
		627: "Ghana", 629: "Gambia", 630: "Guinea-Bissau",

		631: "Equatorial Guinea", 632: "Guinea", 633: "Burkina Faso",
		634: "Kenya", 635: "France - Kerguelen Islands",
		636: "Liberia", 637: "Liberia", 638: "South Sudan",
		642: "Libya", 644: "Lesotho", 645: "Mauritius",
		647: "Madagascar", 649: "Mali", 650: "Mozambique",
		654: "Mauritania", 655: "Malawi", 656: "Niger",
		657: "Nigeria", 659: "Namibia", 660: "France - Reunion",

		661: "Rwanda", 662: "Sudan", 663: "Senegal",
		664: "Seychelles", 665: "United Kingdom - Saint Helena",
		666: "Somalia", 667: "Sierra Leone",
		668: "Sao Tome and Principe", 669: "Eswatini",
		670: "Chad", 671: "Togo", 672: "Tunisia",
		674: "Tanzania", 675: "Uganda",
		676: "Democratic Republic of the Congo",
		677: "Tanzania", 678: "Zambia", 679: "Zimbabwe",

		701: "Argentina", 710: "Brazil", 720: "Bolivia",
		725: "Chile", 730: "Colombia", 735: "Ecuador",
		740: "United Kingdom - Falkland Islands",
		745: "France - Guiana", 750: "Guyana",
		755: "Paraguay", 760: "Peru", 765: "Suriname",
		770: "Uruguay", 775: "Venezuela",
	}
	mid = mmsi // 1_000_000
	return Coutrydic.get(mid, "不明")

def output_PositionReport(msg):	
	StatusDic = {
		0:  '機関使用中で航行', 1:  '錨泊中', 2:  '操船不能', 3:  '操船制限', 4:  '喫水制限', 5:  '係留中', 6:  '座礁中', 7:  '漁労中', 8:  '帆走中',
		9:  '予約', 10: '予約', 11: '予約', 12: '予約', 13: '予約',
		14: '遭難信号', 15: '未定義'
	}

	msgdata = {
		'type': 'posdata',
		'id':msg['MetaData']['MMSI'],
		'data':{
        	'MMSI': msg['MetaData']['MMSI'],
			'Country': get_country_mmsi(msg['MetaData']['MMSI']),
        	'ShipName': msg['MetaData']['ShipName'],
        	'latitude': msg['MetaData']['latitude'],
        	'longitude': msg['MetaData']['longitude'],
        	'time_utc': msg['MetaData']['time_utc'],
        	'Cog': msg['Message']['PositionReport']['Cog'],
        	'Sog': msg['Message']['PositionReport']['Sog'],
        	'TrueHeading': msg['Message']['PositionReport']['TrueHeading'],
        	'RateOfTurn': msg['Message']['PositionReport']['RateOfTurn'],
        	'NavigationalStatus_type': msg['Message']['PositionReport']['NavigationalStatus'],
        	'NavigationalStatus': StatusDic[msg['Message']['PositionReport']['NavigationalStatus']],
		}
    }
	return msgdata

def output_ShipStaticData(msg):
	msgdata = {
		'type': 'shipdata',
		'id':msg['MetaData']['MMSI'],
		'data':{
        	'MMSI': msg['MetaData']['MMSI'],
       		'Destination': msg['Message']['ShipStaticData']['Destination'],
        	'CallSign': msg['Message']['ShipStaticData']['CallSign'],
        	'A(船首)': msg['Message']['ShipStaticData']['Dimension']['A'],
        	'B(船尾)': msg['Message']['ShipStaticData']['Dimension']['B'],
        	'C(左舷)': msg['Message']['ShipStaticData']['Dimension']['C'],
        	'D(右舷)': msg['Message']['ShipStaticData']['Dimension']['D'],
		}
	}
	return msgdata

def output_StaticDataReport(msg):
	msgdata = {
		'type': 'staticdata',
		'id':msg['MetaData']['MMSI'],
		'data':{
        	'MMSI': msg['MetaData']['MMSI'],
        	'ShipType': msg['Message']['StaticDataReport']['ReportB']['ShipType'],
		}
	}
	return msgdata

recoreds = []
async def recoreds_ais(q):
	global recoreds

	while True:
		data = await q.get()
		mmsi = data["id"]

		# 既存データを探す
		found = None
		for r in recoreds:
			if r["id"] == mmsi:
				found = r
				break

		# 見つからなければ新規作成
		if found is None:
			found = {"id": mmsi, "data": {}}
			recoreds.append(found)

		# データをマージ(上書きではなく追加)
		found["data"].update(data["data"])



async def save_to_csv(sec):
	global recoreds
	filename = 'ais_output.csv'

	while 1:
		await asyncio.sleep(sec)
		print(len(recoreds))
		df = pd.DataFrame([r['data'] for r in recoreds])
		df.to_csv(filename, index=False, encoding='utf-8-sig')

async def get_ais(url,key,range,q):
	async with websockets.connect(url) as websocket:
		subscribe_msg = {
			'APIKey': key,
			'BoundingBoxes': [range],
			'FilterMessageTypes': ['PositionReport','ShipStaticData','StaticDataReport']
		}

		await websocket.send(json.dumps(subscribe_msg))

		async for msg_json in websocket:
			try:
				msg = json.loads(msg_json)
				msg_type = msg.get('MessageType')
				if msg_type == 'PositionReport':
					await q.put(output_PositionReport(msg))
				elif msg_type == 'ShipStaticData':
					await q.put(output_ShipStaticData(msg))
				elif msg_type == 'StaticDataReport':
					await q.put(output_StaticDataReport(msg))

			except json.JSONDecodeError:
				continue

async def main():
    
    q =asyncio.Queue()  # Queue準備

    task1 = asyncio.create_task(get_ais(url,key,range,q))
    task2 = asyncio.create_task(recoreds_ais(q))
    task3 = asyncio.create_task(save_to_csv(10))
    
    await asyncio.gather(task1,task2,task3)


if __name__ == '__main__':
    asyncio.run(main())

戻る

地図にマッピング

foliumを使ってcsvの経度緯度情報からマッピング。

map.py
import pandas as pd
import folium

# CSV 読み込み
df = pd.read_csv("ais_output.csv")

lat_col = "latitude"
lon_col = "longitude"
name_col = "ShipName"

# 欠損行を除外
df = df.dropna(subset=[lat_col, lon_col])

# 空欄なら "Unknown" にする
df[name_col] = df[name_col].fillna("Unknown")
df["NavigationalStatus"] = df["NavigationalStatus"].fillna("Unknown")
df["Country"] = df["Country"].fillna("Unknown")


# 地図の中心をcsvの最初の船にする
center_lat = df[lat_col].iloc[0]
center_lon = df[lon_col].iloc[0]
m = folium.Map(location=[center_lat, center_lon], zoom_start=8)

# マーカ追加
for _, row in df.iterrows():
    popup_text = (
        f"{row[name_col]}<br>"
        f"MMSI:{row['MMSI']}<br>"
        f"Country:{row['Country']}<br>"
        f"{row['NavigationalStatus']}"
    )
    folium.Marker(
        location=[row[lat_col], row[lon_col]],
        popup=popup_text,
        tooltip=row[name_col]
    ).add_to(m)

# HTMLとして保存
m.save("ais_map.html")

戻る

参考記事

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?