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?

Watershedアルゴリズムで木の「樹冠」を1本ずつ切り分ける(LiDAR / 個木セグメンテーション)

1
Posted at

watershed.png

これは何?

Watershed(分水界)アルゴリズムは、地形に水を流す比喩で動く画像セグメンテーション手法だ。

森林LiDARでの使いどころ:

  • 樹高マップ(CHM)から1本1本の樹冠ポリゴンを切り出す
  • 樹冠面積・等価半径の計算(樹高だけでは出せない情報)
  • 炭素量推定の精度向上(面積から断面積を推定)

局所最大値法が「木の頭の座標」を出すのに対し、Watershedは「木が占める面積」まで求められる。


局所最大値法との違い

比較項目 局所最大値法 Watershed
出力 点(樹頂点の座標) ポリゴン(樹冠の輪郭)
樹高
樹冠面積
等価半径 ✕(推定のみ)
計算コスト 軽い やや重い
過分割リスク 低い sigmaが小さいと多め

同じマーカーを使えば検出本数は同じ。違いは「各木の領域」が分かるかどうかだ。


アルゴリズム:山に水を流す

Watershedの直感的なイメージ:

CHM(樹高マップ)を「山の地形」として見る

  高い ── ★         ★         ★     ← 樹頂点(局所最大値)
           |\       /|\       /|
  低い ──  | \─────/ | \─────/ |     ← 谷(樹冠の境界)

手順

  1. CHMを反転する(高い木 → 深い谷になる)
  2. 各樹頂点にマーカー番号を振る(この谷は何番の木か)
  3. Watershedが各マーカーから水を満たしながら領域を広げる
  4. 隣のマーカーの水域と交わった境界 = 樹冠の境界線

樹冠は「隣の木と競合するまで広がる」ので、自然な輪郭が得られる。


Pythonでの実装

1. マーカーの作成

局所最大値法とまったく同じパラメータで種点を作る。

import numpy as np
import rasterio
from scipy.ndimage import gaussian_filter, maximum_filter, label as nd_label

SIGMA       = 1.5   # ガウスフィルターのsigma
WINDOW_SIZE = 5     # 局所最大値の探索窓(px)
MIN_HEIGHT  = 5.0   # 最低樹高(m)

# CHM読み込み
with rasterio.open('../data/processed/height_map.tif') as src:
    chm       = src.read(1).astype('float32')
    transform = src.transform
    crs       = src.crs
    nodata    = src.nodata
    pixel_w   = transform.a
    pixel_h   = abs(transform.e)

if nodata is not None:
    chm[chm == nodata] = np.nan

pixel_area = pixel_w * pixel_h  # m²/pixel

# スムージング
chm_filled = np.where(np.isnan(chm), 0, chm)
chm_smooth = gaussian_filter(chm_filled, sigma=SIGMA)
chm_smooth[np.isnan(chm)] = np.nan
chm_detect = np.where(np.isnan(chm_smooth), 0, chm_smooth)

# 局所最大値マスク
local_max_mask = (
    (chm_detect == maximum_filter(chm_detect, size=WINDOW_SIZE)) &
    (chm_detect >= MIN_HEIGHT) &
    ~np.isnan(chm)
)

# 各局所最大値に一意の整数ラベルを付ける
# 局所最大値は孤立した1点なので、1マーカー = 1本の木
markers, n_markers = nd_label(local_max_mask)
print(f'マーカー数: {n_markers:,}')
# → マーカー数: 944本

nd_label は連結した True 領域ごとに番号を振る。局所最大値は孤立点なので、1マーカー = 1本の木になる。

2. Watershed実行

from skimage.segmentation import watershed

# CHMを反転:高いところ(木のてっぺん)が低い値になる
# → watershedは低い値から広がるので、木のてっぺんからスタートする
chm_inv = -chm_detect

# マスク:CHM > 0の領域のみ(空やnodataを除外)
ws_mask = (chm_detect > 0) & ~np.isnan(chm)

# Watershed実行
ws_labels = watershed(chm_inv, markers=markers, mask=ws_mask)

n_segments = len(np.unique(ws_labels[ws_labels > 0]))
print(f'セグメント数: {n_segments:,}')
# → セグメント数: 944

マーカー数とセグメント数が一致していれば正常動作。

3. メトリクス計算

import pandas as pd

segment_ids = np.unique(ws_labels[ws_labels > 0])
records = []

for sid in segment_ids:
    seg_mask = ws_labels == sid
    heights  = chm[seg_mask]
    heights  = heights[~np.isnan(heights)]
    if len(heights) == 0:
        continue
    px_count     = int(seg_mask.sum())
    crown_area   = px_count * pixel_area          # m²
    crown_radius = float(np.sqrt(crown_area / np.pi))  # 等価半径
    records.append({
        'segment_id'    : int(sid),
        'height_m'      : float(heights.max()),
        'mean_height_m' : float(heights.mean()),
        'crown_area_m2' : float(crown_area),
        'crown_radius_m': crown_radius,
    })

df = pd.DataFrame(records)
print(df[['height_m', 'crown_area_m2', 'crown_radius_m']].describe().round(2))
       height_m  crown_area_m2  crown_radius_m
count    944.00         944.00          944.00
mean      30.44         101.54            5.32
std        8.43          80.90            2.00
min        8.48           6.00            1.38
25%       24.93          48.00            3.91
50%       30.36          80.00            5.05
75%       35.99         129.00            6.41
max       49.99         867.00           16.61

御岳山では平均樹冠面積 101.5 m²、等価半径 5.3 m。高木が多いエリアらしい結果だ。


sigmaが分割数に与える影響

sigmaを変えると検出本数がどう変わるか:

from scipy.ndimage import maximum_filter

sigmas = [0.5, 1.0, 1.5, 2.0, 3.0, 5.0]

for s in sigmas:
    s_smooth = gaussian_filter(chm_filled, sigma=s)
    s_lm = (
        (s_smooth == maximum_filter(s_smooth, size=WINDOW_SIZE)) &
        (s_smooth >= MIN_HEIGHT)
    )
    s_markers, s_n = nd_label(s_lm)
    s_ws = watershed(-s_smooth, markers=s_markers,
                     mask=(s_smooth > 0))
    s_segs = len(np.unique(s_ws[s_ws > 0]))
    mark = '← 採用' if s == 1.5 else ''
    print(f'sigma={s:.1f}: {s_segs:,}{mark}')
sigma=0.5:  1,843本  ← 過分割(1本が複数セグメントに)
sigma=1.0:  1,388本
sigma=1.5:    944本  ← 採用
sigma=2.0:    647本
sigma=3.0:    317本
sigma=5.0:    139本  ← 過統合(複数本が1セグメントに)

sigmaを大きくするほど分割数は単調に減る。「正解」は現地調査データとの照合で決める。


可視化:境界線をCHMに重ねる

import matplotlib.pyplot as plt
from skimage.segmentation import find_boundaries

boundaries = find_boundaries(ws_labels, mode='outer')

fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# 左: CHM + 境界線
axes[0].imshow(chm, cmap='YlGn', vmin=0, vmax=50)
bound_overlay = np.zeros((*chm.shape, 4), dtype=np.float32)
bound_overlay[boundaries] = [0.9, 0.1, 0.1, 0.85]  # 赤い境界線
axes[0].imshow(bound_overlay)
axes[0].set_title(f'CHM + Watershed境界線 ({n_segments:,}本)')

# 右: セグメントをランダムカラーで色分け
rng = np.random.default_rng(42)
n_max = int(ws_labels.max())
palette = np.zeros((n_max + 1, 4), dtype=np.float32)
palette[1:] = np.column_stack([rng.random((n_max, 3)), np.full(n_max, 0.75)])
axes[1].imshow(palette[ws_labels])
axes[1].set_title('セグメント色分け(各色 = 1本)')

plt.tight_layout()
plt.show()

mitake_watershed.png


GeoPackageとして保存(QGIS連携)

import geopandas as gpd
from shapely.geometry import shape
from rasterio.features import shapes as rio_shapes

metrics_dict = df.set_index('segment_id').to_dict('index')
geom_records = []

for geom_dict, seg_id_f in rio_shapes(
    ws_labels.astype(np.int32),
    mask=(ws_labels > 0).astype(np.uint8),
    transform=transform
):
    sid = int(seg_id_f)
    if sid == 0 or sid not in metrics_dict:
        continue
    m = metrics_dict[sid]
    geom_records.append({
        'geometry'      : shape(geom_dict),
        'segment_id'    : sid,
        'height_m'      : m['height_m'],
        'crown_area_m2' : m['crown_area_m2'],
        'crown_radius_m': m['crown_radius_m'],
    })

gdf = gpd.GeoDataFrame(geom_records, crs=crs)
gdf.to_file('../data/processed/mitake_watershed.gpkg', driver='GPKG')
print(f'保存完了: {len(gdf):,}ポリゴン')
# → 保存完了: 944ポリゴン

QGISで開くと、1本ごとの樹冠が面データとして表示される。シンボロジで crown_area_m2 を色分けすると、大木の分布が一目でわかる。


まとめ

項目 内容
何をするか CHMを反転し、各樹頂点(マーカー)から領域を広げて樹冠を切り分ける
核心 watershed(-chm, markers=markers, mask=...) の3引数
マーカー 局所最大値法と同じ → 検出本数は変わらない
付加価値 樹冠面積・等価半径が計算できる
sigmaの影響 小さい → 過分割、大きい → 過統合。現地データで調整する
# 最小構成
chm_inv  = -chm_detect
markers, _ = nd_label(local_max_mask)
ws_labels  = watershed(chm_inv, markers=markers, mask=ws_mask)

このシリーズは、東京都多摩地域の森林LiDARデータをPythonで分析する中で出会ったアルゴリズムを1つずつ解説しています。
実装コードは tama-forest notebooks で公開中。

作者のその他の実験 → https://niikun.net

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?