#はじめに
全天球画像のビューアを作っているのですが、適当に撮影した写真をテストに使って作ると、適当なビューアになってしまいます。
例えば、仮想カメラに設定した画角が水平画角なのか垂直画角なのか対角画角なのかよくわからんのです。
また、環境光の設定をミスると明るかったり暗かったり変なことになります。
そこで、いろいろテストしやすい全天球画像を作ります。
手っ取り早くテスト画像だけ欲しい方はスクロールして最後の画像を拾ってください。
#立方体6面画像の用意
立方体(正六面体)のど真ん中で撮影した風の画像があるとテストしやすいので、まずは立方体を構成する6面の画像を用意します。
ビューアで生成する球体の分割数を調整するにあたって、グリッドが描かれているとひずみのチェックになります。
画角を見るために、画角を示す同心円もあると便利です。
明るさなどのチェックのため、グレースケールや色相なども用意します。
また、どちらを向いているのかを分かりやすくするため、正面・背面・天面・底面・右面・左面 をどどーんと表示します。
という方針でコードを。
import math
from PIL import Image, ImageDraw, ImageFont, ImageColor
def createCubeImage(filename, text):
oneSideLen = 4000
gridColor = (127,16,131)
circleColor = (91,190,228)
circleColor2 = (153,215,238)
degreeTextFillColor = (91,190,228)
degreeTextStrokeColor = (30,138,179)
textFillColor = (51,174,221)
textStrokeColor = (30,138,179)
im = Image.new("RGB",(oneSideLen,oneSideLen),"white")
draw = ImageDraw.Draw(im)
for i in range(10+1) :
ii = i*oneSideLen/10
ll = oneSideLen/10
gray = int(i*255/(10-1))
draw.rectangle((ii, 0, ii+ll, ll), fill=(gray,gray,gray))
hsl = "hsl(" + str(i*360/10)+", 100%, 50%)"
draw.rectangle((ii, ll*(10-1), ii+ll, ll*(10-0)), fill=hsl)
for i in range(10+1) :
ii = i*oneSideLen/10
draw.line((ii, 0, ii, oneSideLen-1), fill=gridColor, width=21)
draw.line((0, ii, oneSideLen-1, ii), fill=gridColor, width=21)
txtSize = int(oneSideLen/2.2)
fnt = ImageFont.truetype('./ipaexg.ttf',txtSize)
txtWidth = draw.textsize(text,font=fnt, stroke_width=3)[0]
draw.text((oneSideLen/2-txtWidth/2,oneSideLen/2-txtSize/2),text,font=fnt,fill=textFillColor, stroke_fill=textStrokeColor, stroke_width=10)
fnt = ImageFont.truetype('./ipaexg.ttf',int(oneSideLen/50))
for th in range(10,110+1,10) :
# 10°
r = math.tan(math.radians(th/2))*oneSideLen/2
draw.ellipse((oneSideLen/2-r, oneSideLen/2-r, oneSideLen/2+r, oneSideLen/2+r), outline=circleColor, width=9)
draw.text((oneSideLen/2,oneSideLen/2-r),str(th)+"°",font=fnt,fill=degreeTextFillColor, stroke_fill=degreeTextStrokeColor, stroke_width=4)
# 5°
r = math.tan(math.radians((th-5)/2))*oneSideLen/2
draw.ellipse((oneSideLen/2-r, oneSideLen/2-r, oneSideLen/2+r, oneSideLen/2+r), outline=circleColor2, width=5)
im.resize((1000,1000), Image.LANCZOS).save(filename)
createCubeImage("testcube@front.png","正面")
createCubeImage("testcube@back.png","背面")
createCubeImage("testcube@top.png","天面")
createCubeImage("testcube@bottom.png","底面")
createCubeImage("testcube@right.png","右面")
createCubeImage("testcube@left.png","左面")
# import subprocess
# subprocess.Popen(['start', "testcube@front.png"], shell=True)
環境は Python3 + PIL です。また、「正面」などの文字を描くフォントとして ipaexg.ttf を使用しております。
注目すべき点は、同心円の半径の計算に三角関数のtanを使っているところです。数学キライですがかんばりました。
文字や円でのアンチエイリアスのやり方がわからなかったので、代替手段として4000x4000の画像に描画し、最後に1000x1000に縮小しています。
色は日向坂46っぽい水色と紫をキーカラーとしました。
環境構築は本題じゃないので省略します。
無事に動くと、次の6枚の画像ファイルが得られます。
お気に召さなければ、ソースを改変するなり、画像を直接編集するなりしましょう。
#6面画像からEquirectangularに変換する
ここの自作も考えたのですが、ツールを見つけたのでそれを使います。
Cube2DMです。
GUIで次のように設定して、変換開始をポチります。
うまく動けば次の画像ファイルが得られます。
#天頂補正データありのテスト画像ファイル
全天球カメラの代名詞、RICOH THETAは加速度センサーを持っていて、撮影時の天頂方向を認識し、画像に天頂方向を埋め込むことができます。ということは、ビューアもそれに対応して画像を回転できるようにしなければなりません。
適当に斜めに撮った写真を使うと、球体をどう回転したら真っすぐに見えるようになるのか、さっぱりわかりません。本来なら仕様書を読んで、XYZどの軸周りにどっち方向に回せばいいのか調べるべきですが、面倒なので勘で回して真っすぐならヨシ! となりがちです。
しかし、3軸×2方向のロール・ピッチで36通りの組み合わせがあり、挫折しました。
ロール・ピッチの片方向だけ回ってる画像があると、その方向だけ合わせればいいので楽です。また、最終的な確認用に、ロール・ピッチ両方向回っている画像があると、掛算の順番のチェックになります。(行列の掛算なので可換じゃないのです!)
回転も Cube2DM のお世話になります。
抑角がPitch、画面回転がRollのようです。片方に値を入れたものと、両方に値を入れたもので都合3枚の画像を作ります。
しかしながら、Cube2DMでの出力結果には天頂補正のXMP情報が入ってないのでテストに使えません。そのため、別途XMP情報を追加します。
#XMP情報の追加
またまたPython3を使います。
コードに型を書かないのに実行時の型チェックが辛いという、型付けのダメなところどりした言語が嫌いなのですが、なんで流行ってるんですかね。(なんで使ってるんだ>自分)
import struct
def addXmp(srcfilename,dstfilename,xmp):
def read2Bytes() :
return int.from_bytes(sfp.read(2), 'big')
def mylog(message) :
# デバッグ時はコメントアウトを戻す
#print(message)
pass
with open(srcfilename, 'rb') as sfp:
with open(dstfilename, 'wb') as dfp:
try :
if read2Bytes() != 0xFFD8 :
# 先頭のSOIマーカが無い→JPEGファイルでない
mylog('SOI marker not found.')
return None
dfp.write(struct.pack('!H', 0xFFD8))
# ダミーExif追加
#with open('dummyExif.bin', 'rb') as fp:
# dfp.write(fp.read())
xmpWithHeader = b"http://ns.adobe.com/xap/1.0/\0<?xpacket begin=\"\xEF\xBB\xBF\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
xmpWithHeader += xmp
dfp.write(struct.pack('!H', 0xFFE1))
dfp.write(struct.pack('!H', len(xmpWithHeader)+2))
dfp.write(xmpWithHeader)
tail = sfp.read()
dfp.write(tail)
dfp.close()
except Exception as e:
mylog(e)
return None
if __name__ == '__main__':
#basefilename = 'Zenith-Pitch'
basefilename = 'Zenith-Roll'
#basefilename = 'Zenith-RollPitch'
srcfilename = basefilename + '-withoutXMP.jpg'
dstfilename = 'testcube-' + basefilename + '.jpg'
xmpfilename = basefilename + '.xmp'
with open(xmpfilename, 'rb') as fp:
xmp = fp.read()
addXmp(srcfilename,dstfilename,xmp)
exit(0)
適当に用意したXMPファイルを、JPGファイル内にぶち込みます。SOIマーカの直後に割り込ませるように追加します。
ソースの後ろの方でファイル名をゴニョゴニョしてますが、適当にいじって動くようにしてください。
xmpファイルはテキストエディタで用意します。
<x:xmpmeta xmlns:x="adobe:ns:meta/" xmptk="my xmp editor">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:GPano="http://ns.google.com/photos/1.0/panorama/">
<GPano:ProjectionType>equirectangular</GPano:ProjectionType>
<GPano:UsePanoramaViewer>True</GPano:UsePanoramaViewer>
<GPano:CroppedAreaImageWidthPixels>4000</GPano:CroppedAreaImageWidthPixels>
<GPano:CroppedAreaImageHeightPixels>2000</GPano:CroppedAreaImageHeightPixels>
<GPano:FullPanoWidthPixels>4000</GPano:FullPanoWidthPixels>
<GPano:FullPanoHeightPixels>2000</GPano:FullPanoHeightPixels>
<GPano:CroppedAreaLeftPixels>0</GPano:CroppedAreaLeftPixels>
<GPano:CroppedAreaTopPixels>0</GPano:CroppedAreaTopPixels>
<GPano:PoseHeadingDegrees>0</GPano:PoseHeadingDegrees>
<GPano:PosePitchDegrees>60</GPano:PosePitchDegrees>
<GPano:PoseRollDegrees>45</GPano:PoseRollDegrees>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="r"?>
GPano:PosePitchDegrees や GPano:PoseRollDegrees の値を適切に変えて、3つのXMPファイルを用意し、jpgファイルに追加しましょう。
なお、このプログラムは入力のJPGファイルにXMP情報がないことが前提です。すでにある場合は2重に登録されると思います。
#できあがり
そして出来上がったのが次の3枚の画像です。
#おわりに
全天球画像ビューアのテストなう。に使っていいよ!