この記事は tokyometer.com — 東京都の路上パーキングメーターと駐車規制を可視化するWebアプリ — の開発で得た技術的知見をまとめたものです。
はじめに
東京都心でパーキングメーターに停めようとしたら、実はその区間が駐車禁止だった——そんな経験はないでしょうか。警視庁が公開するパーキングメーターの設置位置データと、JARTIC(日本道路交通情報センター)が公開する駐車規制データはそれぞれ独立したデータソースであり、座標系も頂点密度もまったく異なります。
この記事では、これら2つのデータセットを統合する際に直面した技術的課題と、それを解決するために設計・実装したマッチングアルゴリズムの詳細を解説します。
対象読者: GeoJSON・空間データの統合に興味がある方、位置情報のファジーマッチングを実装したい方
1. データソースの概要
1-1. 警視庁パーキングメーターデータ(parkingmeter.geojson)
出典は parkingmeter.jp/opendata で公開されている警視庁のパーキングメーター・パーキングチケット設置場所のGeoJSONです。
フィーチャ数: 752区画
ジオメトリ: MultiLineString(1区画が複数セグメントを持つ場合がある)
主な属性: 識別id, 利用時間, 制限時間, 手数料, 種別, 制限事項1, ...
頂点密度: 細かい(1セグメントあたり数点〜数十点、数m間隔)
1-2. JARTIC交通規制データ(parking_restriction.geojson)
JARTICがCC BY 4.0で公開する東京都の交通規制データをGeoJSON変換したものです。
フィーチャ数: 33,210本
ジオメトリ: LineString / MultiLineString
主な属性: c(規制種別), tt(時間タイプ), s(開始時刻), e(終了時刻)
頂点密度: 粗い(50〜180m間隔)
1-3. 統合の目的
パーキングメーター区画752件それぞれに対して、「この区画は駐車禁止規制と重複しているか?」を判定し、has_restrictionフラグとポイントごとのpoint_flagsを付与します。これにより、地図上で「メーターはあるけど実は駐禁」の区間を紫色で警告表示できます。
2. 統合における根本的な課題
課題1: 頂点密度の非対称性
JARTICの規制線は頂点が粗く、50〜180m間隔で頂点が配置されています。一方、メーター区画の座標点は数m単位で密に配置されています。
制限線: A ─────────────────────── B (頂点間100m)
↑
メーター点 M ← 真の横断距離 = 8m
↓
問題: M→Aの斜め距離 = 50m → 閾値を超えて検出されない
課題2: 道路幅による座標ズレ
同一道路でも、反対車線にある規制線との横断距離は最大23m(白山通りなど広い道路)に達します。
課題3: 交差点付近の偶発的近接
交差点では異なる道路の制限線端点が偶然近接するため、方向が全く異なる規制を拾ってしまいます。
3. アルゴリズム設計: 段階的な精度改善
benchmark_approaches.py に比較ロジックが残っています。
3-1. Approach A: 単純な距離閾値(8m)
def check_A(segs, thresh_m=8.0):
for seg in segs:
for c in seg:
if nearby_restrict_pts(c[0], c[1], thresh_m):
return True
return False
3-2. Approach B: 密度ベース(同一ri ≥ 2点)
def check_B(segs, thresh_m=8.0, min_hits=2):
for seg in segs:
ri_counts = {}
for c in seg:
for ri, d, rdir in nearby_restrict_pts(c[0], c[1], thresh_m):
ri_counts[ri] = ri_counts.get(ri, 0) + 1
if any(v >= min_hits for v in ri_counts.values()):
return True
return False
3-3. Approach C: 距離+方向角フィルタ
def check_C(segs, thresh_m=8.0, angle_max=45.0):
for seg in segs:
mdir = seg_direction_deg(seg)
for c in seg:
for ri, d, rdir in nearby_restrict_pts(c[0], c[1], thresh_m):
if angle_diff(mdir, rdir) <= angle_max:
return True
return False
3-4. 最終版: リサンプリング+方向+密度+カバレッジ
最終的に add_point_flags.py で採用されたアルゴリズムは、上記すべての課題を解決する4層構造です。
4. 最終アルゴリズムの詳細(add_point_flags.py)
4-1. 定数設計
THRESH = 0.000225 # ≈25m Chebyshev閾値
PARALLEL_MIN = 0.90 # |cos θ| ≈26°以内を同一道路とみなす
RESAMPLE_DEG = 0.000090 # ≈10m リサンプリング間隔
CELL = THRESH * 10
- THRESH = 0.000225°(≈25m): 白山通りなど広い幹線道路で反対車線の規制線までの横断距離が最大23m
-
PARALLEL_MIN = 0.90:
|cos θ| ≥ 0.90、θ ≈ 26°以内を「同一方向」と判定 - RESAMPLE_DEG = 0.000090°(≈10m): 粗い制限線を10m間隔でリサンプリング
4-2. Step 1: リサンプリングと空間インデックス
for c in r_seg:
add_point(c[0], c[1])
sample_count += 1
for k in range(len(r_seg) - 1):
a, b = r_seg[k], r_seg[k+1]
gap = math.sqrt((b[0]-a[0])**2 + (b[1]-a[1])**2)
if gap <= RESAMPLE_DEG:
continue
n_steps = int(gap / RESAMPLE_DEG)
for s in range(1, n_steps):
t = s / (n_steps + 1)
add_point(a[0] + t*(b[0]-a[0]), a[1] + t*(b[1]-a[1]))
グリッドベースの空間インデックスに格納。3×3展開でセル境界をまたぐ近傍探索に対応します。
def add_point(lng, lat, _ri=ri, _rdx=rdx, _rdy=rdy):
cx, cy = int(lng/CELL), int(lat/CELL)
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
grid.setdefault((cx+dx, cy+dy), []).append(
(lng, lat, _rdx, _rdy, _ri))
4-3. Step 2: 方向ベクトルフィルタリング
def nearest_ri(lng, lat, mdx, mdy):
cx, cy = int(lng/CELL), int(lat/CELL)
has_dir = (mdx != 0.0 or mdy != 0.0)
best_ri, best_d = None, THRESH
for (rlng, rlat, rdx, rdy, ri) in grid.get((cx, cy), []):
if has_dir and (rdx != 0.0 or rdy != 0.0):
if abs(mdx*rdx + mdy*rdy) < PARALLEL_MIN:
continue
d = max(abs(lat - rlat), abs(lng - rlng))
if d < best_d:
best_d, best_ri = d, ri
return best_ri
4-4. Step 3: 3条件による判定
matches = [nearest_ri(c[0], c[1], mdx, mdy) for c in seg]
ri_counts = Counter(ri for ri in matches if ri is not None)
n = len(seg)
total_hits = sum(1 for m in matches if m is not None)
has_multi_ri = any(cnt >= 2 for cnt in ri_counts.values())
span_frac = max_contiguous_span_frac(matches, seg)
if has_multi_ri and total_hits * 2 > n and span_frac >= MIN_SPAN_FRAC:
flags = [1 if matches[pi] is not None else 0 for pi in range(n)]
else:
flags = [0] * n
条件A: 同一ri ≥ 2点 — 交差点の偶然1点マッチを排除
条件B: 総カバレッジ > 50% — 複数riの合計で判定(zone #475対応)
条件C: 最長連続カバレッジ ≥ 40% — 端だけマッチする偽陽性を排除
MIN_SPAN_FRAC = 0.40
def max_contiguous_span_frac(matches, seg):
total = earth_m(seg[-1][0] - seg[0][0], seg[-1][1] - seg[0][1])
if total < 1.0: return 1.0
best, n, i = 0.0, len(seg), 0
while i < n:
if matches[i] is not None:
j = i + 1
while j < n and matches[j] is not None: j += 1
if j - i >= 2:
run_span = earth_m(seg[j-1][0]-seg[i][0], seg[j-1][1]-seg[i][1])
best = max(best, run_span)
i = j
else: i += 1
return best / total
4-5. 地球上の距離計算
def earth_m(dlng, dlat):
return math.sqrt((dlng * 90946) ** 2 + (dlat * 111320) ** 2)
経度1° ≈ 90,946m(cos(35.7°) × 111,320)、緯度1° ≈ 111,320m。東京都内の狭い範囲ではHaversineとの誤差は無視できます。
5. ポイントレベルのフラグ付与と描画
5-1. point_flags の構造
{
"has_restriction": true,
"segment_restriction_flags": [false, true, false],
"point_flags": [[0,0,0,0], [0,1,1,1,1,1,0], [0,0,0]],
"restriction_info": [
{"時間タイプ": "always", "規制時間": "終日"}
]
}
5-2. フロントエンドでの描画(ランレングス分割)
let runStart = 0;
let runFlag = ptFlags[0] === 1;
for (let pi = 1; pi < latlngs.length; pi++) {
const f = ptFlags[pi] === 1;
if (f !== runFlag) {
emitRun(runStart, pi, runFlag);
runStart = pi;
runFlag = f;
}
}
emitRun(runStart, latlngs.length - 1, runFlag);
| 状態 | 色 | 用途 |
|---|---|---|
| 規制なし | 緑 #2ECC71
|
安全に停められる |
| 規制あり | 紫 #9B59B6
|
メーターあるが駐禁と重複 |
| 今無料 | 青 #2980b9
|
時間帯外で無料 |
6. 重複判定の初期版(recalc_overlap.py)
THRESH = 0.0001 # ≈11m
def primary_segment(geometry):
if geometry['type'] == 'MultiLineString':
return max(geometry['coordinates'], key=len)
return geometry['coordinates']
MultiLineStringの場合は最多頂点のセグメントのみを判定対象。JARTICデータに離れた断片が混入しているケースへの対策です。
7. フロントエンド側のリアルタイム重複判定
let _meterGrid = null;
function buildMeterGrid() {
if (_meterGrid) return _meterGrid;
_meterGrid = new Set();
const step = 0.001;
meterFeatures.forEach(f => {
f.geometry.coordinates.forEach(seg => {
seg.forEach(c => {
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
_meterGrid.add(
(c[0]+dx*step).toFixed(3)+','+(c[1]+dy*step).toFixed(3));
});
});
});
return _meterGrid;
}
function overlapsOperatingMeter(restrictFeature) {
const g = buildMeterGrid();
return restrictFeature.geometry.coordinates.some(
c => g.has(c[0].toFixed(3)+','+c[1].toFixed(3)));
}
.toFixed(3) で小数3桁に丸めてハッシュキーとし、Set の O(1) 検索で近傍判定を実現しています。
8. 偽陽性への対処
8-1. アルゴリズムレベル
3条件(密度・カバレッジ・連続性)+方向フィルタが最大の偽陽性排除手段です。
8-2. 手動除外リスト
const RESTRICT_EXCLUDE_ZONES = new Set([613, 1080]);
Zone 1080は空間結合の偽ヒット、Zone 613は反対車線のみの規制が検出されたケースです。
8-3. 要確認ゾーンの注記表示
完全に偽陽性と断定できないゾーンには、ポップアップ上で注意書きを表示します。
9. データパイプライン全体像
parkingmeter.jp JARTIC OpenData
(警視庁メーター) (交通規制CSV→GeoJSON)
parkingmeter.geojson parking_restriction.geojson
752 MultiLineString 33,210 LineString
| |
v v
+--------------------------------------+
| add_point_flags.py |
| 1. 制限線を10m間隔でリサンプリング |
| 2. グリッド空間インデックス構築 |
| 3. 方向ベクトル計算(始点→終点) |
| 4. Chebyshev距離<25m + |cosθ|≥0.90 |
| 5. 3条件判定(密度/カバレッジ/連続性) |
| 6. point_flags + restriction_info付与 |
+--------------------------------------+
|
v
+--------------------------------------+
| parkingmeter.geojson(統合済み) |
+--------------------------------------+
|
v
+--------------------------------------+
| index.html (Leaflet.js) |
| - point_flagsでポリライン色分け |
| - isFreeNow(): リアルタイム時間帯判定 |
| - overlapsOperatingMeter(): 重複判定 |
| - RESTRICT_EXCLUDE_ZONES: 偽陽性除外 |
+--------------------------------------+
10. 性能に関する考慮
10-1. GeoJSONファイルサイズの削減
規制GeoJSONを15MB→6MBに削減。属性名を短縮(c, tt, s, e)し、座標精度を丸めることでサイズ60%削減。
10-2. 遅延ロード
fetch('./data/parking_restriction.geojson', { signal: ctrl.signal })
.then(r => r.json())
.then(data => {
restrictFeatures = data.features;
restrictLoaded = true;
renderAll();
});
まとめ
異なるデータソースの空間統合は、単純な距離閾値だけでは不十分で、以下の要素を組み合わせる必要がありました。
- リサンプリング: 頂点密度の非対称性を解消(10m間隔で補間)
-
方向ベクトル: 交差する別道路の規制を排除(
|cos θ| ≥ 0.90) - 密度条件: 偶然の1点マッチを除外(同一ri ≥ 2点)
- カバレッジ条件: 端だけマッチする偽陽性を除外(総ヒット > 50%)
- 連続性条件: 地理的に散在するマッチを除外(連続区間 ≥ 40%)
- 手動除外: アルゴリズムで排除しきれない反対車線ケースの対処
これらの手法は、パーキングメーターに限らず、異なるソースのGeoJSONを空間結合する場面で広く応用できるはずです。
データ出典: JARTIC交通規制情報 (CC BY 4.0) / parkingmeter.jp (CC BY 4.0)