全天球カメラを用いた天空視界の定量的評価
はじめに
衛星測位や無線通信の屋外実験を行うとき、地形や建物などによる天空の遮蔽(しゃへい)を数値的に表現できると、その環境を説明しやすくなります。全天球カメラにて撮影した画像ファイルから、Pythonコードにより、空を見渡せる割合である天空視界率(SVF: sky-view factor)を求めてみました。SVFは樹冠開空度とも呼ばれます。
全天球カメラ
全天球カメラとは、すべての景色を1ショットで取り込むことのできるカメラです。これは、複数の広角レンズを備えた撮像素子により同時に撮影し、これらの映像つなぎ合わせを行って、すべての景色を1枚の写真に収めます。
つなぎ合わされた画像は、標準的なJPEG画像ファイルにて出力されますので、一般のパソコンやスマートフォンでも見ることができます。しかし、この映像は、広角レンズにて撮影されたものなので、そのままでは湾曲して表示されます。この画像ファイルは、カメラ添付アプリによりVR表示できますが、地図でお馴染みのメルカトル図法と同等の表現形式ですので、多くの方々が様々な画像形式への変換にチャレンジされています。
ここでは、広角レンズを2枚備えた、Ricoh Theta Vを用います。このカメラは、姿勢センサにより画像の天地補正(zenith correction)ができるので、また、スマートフォンアプリによる遠隔シャッタが利用できるので、とても便利です。例えば、広島市立大学RTK基準局の3Dパノラマ写真では、このカメラにて撮影した画像をJavaScriptでレンダリングしています。
天空視界率の計算
ここでは、画像輝度の明るい部分を空とみなして、画像中の空の割合を求めます。この輝度しきい値は、大津の二値化法にて求められます。
天空視界率(SVF)は、(1) 天地補正を前提に水平から天頂までの上半球の切り取り、(2) 正距円筒図法(equidistant cylindrical projection)から正積図法(equidistant and equal-area projection)への変換、(3) グレイスケール化、(4) 大津の二値化法による輝度しきい値決定、(5) 二値化の実行、(6) しきい値を超えたピクセル割合の計算、の手順にて求めます。この画像処理のために、OpenCVを、またPythonモジュールnumpy
、pillow
、scikit-image
を用いています。
pip install pillow scikit-image
ここで作成したPythonコードskyview.py
は次のとおりです。-f
オプションで魚眼画像の出力を、-b
オプションで二値化画像の出力を、-t
オプションで二値化しきい値調整を、それぞれ行うようになっています。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is for sky view factor calculation
# pip install pillow scikit-image
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
import sys
import argparse
from scipy.ndimage import map_coordinates
from skimage.filters import threshold_otsu
from skimage.color import rgb2gray
def remap_equidistant_to_fisheye_array(img, fov_deg=180):
# Calculate the new dimensions for the square fisheye image
# Assuming the field of view is a full hemisphere
fov = np.radians(fov_deg)
h, w = img.shape[:2]
w_new = int(w * fov / np.pi)
h_new = w_new
# Define the new image with the same channel count as the original
fisheye_img = np.zeros((h_new, w_new, img.shape[2]), dtype=img.dtype)
# Coordinates of the new image
xx_new, yy_new = np.meshgrid(np.arange(w_new), np.arange(h_new))
# Re-center the coordinates at the center of the fisheye image
xx_new = xx_new - w_new / 2.0
yy_new = yy_new - w_new / 2.0
# Calculate the r, theta coordinates
r = np.sqrt(xx_new**2 + yy_new**2)
theta = np.arctan2(-yy_new, xx_new) + np.pi
# Calculate the corresponding coordinates in the source image
xx = (w * theta / (2 * np.pi)).astype(np.float32)
yy = (h * r / r.max()).astype(np.float32)
# Map the polar coordinates back to the cylindrical coordinates
map_x = xx.clip(0, w-1)
map_y = yy.clip(0, h-1)
# Use scipy's map_coordinates for efficient remapping
for i in range(img.shape[2]):
fisheye_img[:, :, i] = map_coordinates(img[:, :, i], [map_y.flatten(), map_x.flatten()], order=1).reshape((h_new, w_new))
return Image.fromarray(fisheye_img)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Calculate the sky view factor of a fisheye image')
parser.add_argument('images', type=str, nargs='+', help='Path to the original image')
parser.add_argument('-f', '--fisheye', action='store_true' , help='switch to save fisheye image')
parser.add_argument('-b', '--binary', action='store_true', help='switch to save binary image')
parser.add_argument('-t', '--threshold', type=float, default=0.0, help='threshold offset')
args = parser.parse_args()
for image in args.images: # For each image in the command line arguments
# Path to the original image
img_original = np.array(Image.open(image))
# Cut the original image to only keep the sky
img_sky = img_original[:img_original.shape[0] // 2, :]
# Convert the sky part to fisheye
fisheye_sky_image = remap_equidistant_to_fisheye_array(img_sky)
# Save the fisheye sky image
if args.fisheye:
path_image_fisheye = image.split('.')[0] + '_fish.jpg'
fisheye_sky_image.save(path_image_fisheye)
# Convert the image to grayscale for thresholding
gray_fisheye_sky_image = rgb2gray(fisheye_sky_image)
# Find the Otsu threshold value
threshold_value = threshold_otsu(gray_fisheye_sky_image)
threshold_value += args.threshold
# Apply the threshold to binarize the image
binary_fisheye_sky_image = gray_fisheye_sky_image > threshold_value
# Calculate the proportion of sky and clouds
# Assuming that sky is white after thresholding
sky_ratio = np.sum(binary_fisheye_sky_image) / binary_fisheye_sky_image.size
if args.binary:
path_image_sky = image.split('.')[0] + '_bin.jpg'
# Convert the binary image back to uint8 for saving
binary_fisheye_sky_image = (binary_fisheye_sky_image * 255).astype(np.uint8)
binary_fisheye_sky_image = Image.fromarray(binary_fisheye_sky_image)
# Save the binary image
binary_fisheye_sky_image.save(path_image_sky)
print(f'{image} sky ratio: {sky_ratio * 100:.1f}%')
全天球カメラで撮影した次の写真を例に、SVFを求めます。
多くの画像を整理するため、私は、カメラ内部時刻を設定の上、jheadにてJPEGファイルのEXIF(イグジフ、Exchangeable image file format)情報をもとにファイル名を日時を含むものに変更しています(jhead -nf%y%m%d-%H%M%S -autorot *.jpg
)。私は、仕事もプライベートも、この方法で画像を整理しています。Macならばbrew install jhead
で、Debian Linuxならばapt install jhead -y
で、jhead
を簡単にインストールできます。
この画像ファイル240318-144315.jpg
を処理します。これは、2024-03-18 14:43:15 JSTに撮影したものです。
$ ./skyview.py -f -b 240318-144315.jpg
240318-144315.jpg sky ratio: 52.4%
SVFは52.4%と評価されました。ここで出力された魚眼画像240318-144315_fish.jpg
と二値化画像240318-144315_bin.jpg
は次のとおりです。
240318-144315_fish.jpg
240318-144315_bin.jpg
二値化画像において、明らかな空の部分が遮蔽されているとして扱われています。そこで、空の部分を空と認識されるように、しきい値の調整と2値化画像の表示を繰り返しました。最終的に、この画像の二値化しきい値として-0.30を選択し、このSVFを92.2%と評価しました。
$ ./skyview.py -b -t -0.30 240318-144315.jpg
240318-144315.jpg sky ratio: 92.2%
240318-144315_bin.jpg
このしきい値では、建物の一部が空とみなされてしまいました。しきい値調整のみの方法では、この程度が限界だと思っています。
天空視界率の観測
全天球カメラとスマートフォンを外に持ち出し、職場の周囲道路の天空視界率を観測します。あらかじめ、自動車に乗せたGPS受信機により大学周辺道路を測位しながら走行し、RTKLIBのrtkplot.exe
により走行コースをプロットして、印刷しておきました。
全天球カメラを走行コース上に設置し、スマートフォンアプリにて遠隔撮影を行って、その時刻を走行コース上に記録します。アナログな方法です。
その後、全天球カメラから画像ファイルをPCにダウンロードし、上述のjhead
を用いた方法にてファイル名に日時を含むようリネームしました。そして、次のようなシェルスクリプトを作成し、すべての画像ファイルに対して上述のskyview.py
を実行し、魚眼画像と二値画像を見比べ、しきい値を調整しました。かなりアナログな方法です。
./skyview.py -b -f -t -0.30 240318-144315.jpg
./skyview.py -b -f -t -0.30 240318-144447.jpg
./skyview.py -b -f -t -0.30 240318-144804.jpg
./skyview.py -b -f -t -0.30 240318-144957.jpg
./skyview.py -b -f -t -0.00 240318-145410.jpg
./skyview.py -b -f -t -0.20 240318-145529.jpg
./skyview.py -b -f -t -0.10 240318-145731.jpg
./skyview.py -b -f -t -0.10 240318-150032.jpg
./skyview.py -b -f -t 0.00 240318-150231.jpg
./skyview.py -b -f -t -0.25 240318-150350.jpg
./skyview.py -b -f -t -0.25 240318-150716.jpg
./skyview.py -b -f -t 0.00 240318-151024.jpg
./skyview.py -b -f -t -0.15 240318-151251.jpg
./skyview.py -b -f -t 0.13 240318-151456.jpg
./skyview.py -b -f -t -0.30 240318-151751.jpg
このようにして、この走行コース上のSVFを求めました。
240318-144315.jpg sky ratio: 92.2%
240318-144447.jpg sky ratio: 88.3%
240318-144804.jpg sky ratio: 91.4%
240318-144957.jpg sky ratio: 94.5%
240318-145410.jpg sky ratio: 64.9%
240318-145529.jpg sky ratio: 76.0%
240318-145731.jpg sky ratio: 74.7%
240318-150032.jpg sky ratio: 72.8%
240318-150231.jpg sky ratio: 63.9%
240318-150350.jpg sky ratio: 88.4%
240318-150716.jpg sky ratio: 85.8%
240318-151024.jpg sky ratio: 53.2%
240318-151251.jpg sky ratio: 78.7%
240318-151456.jpg sky ratio: 69.3%
240318-151751.jpg sky ratio: 85.0%
そして、ファイル名の時刻情報をもとに、SVF値を走行コースに書き込みました。
これらのJPG画像ファイルのEXIF情報にスマートフォン座標が自動記録されることを期待しましたが、そのような記録はありませんでした。残念。
$ jhead 240318-144315.jpg
File name : 240318-144315.jpg
File size : 4950603 bytes
File date : 2024:03:18 21:07:33
Camera make : RICOH
Camera model : RICOH THETA V
Date/Time : 2024:03:18 14:43:15
Resolution : 5376 x 2688
Flash used : No
Focal length : 1.3mm
Exposure time: 0.0008 s (1/1250)
Aperture : f/2.0
ISO equiv. : 64
Whitebalance : Auto
Metering Mode: pattern
Exposure : program (auto)
JPEG Quality : 95
======= IPTC data: =======
City :
Record vers. : 2
Time Created : 144315+0900
Caption :
DateCreated : 20240318
今後は、自動車のルーフに全天球カメラとGPS受信機を設置し、その動画から走行コースとSVFを自動計算できるようにしたいと思っています。また、Unicore UM982受信機などの進行方向(ヘディング)情報の取れるGPS受信機と画像処理により、全天球映像の正面が常に真北になるように画像記録したいです。現在の方法は、とてもアナログな方法なので。
まとめ
全天球カメラの映像から天空視界率(SVF)を求めました。大津の二値化法を用いて空の割合を求めてきましたが、画像を見ながらのしきい値の調整は必要になりました。撮影時刻情報をもとに撮影場所を同定して、撮影場所とSVFを対応づけました。手作業の部分が多いので、今後は自動化を考えてみたいと思っています。