《Parallel Kozak II》光学追尾装置
この記事はStormworks 第1 Advent Calendar 2024 25日目の記事です。
光学追尾への挑戦
一般に、Stormworksで物体を追尾し、その位置と運動を計算するためには、レーダーが使われます。
Stormworksにおけるレーダーは、ビークルのPhysics Bodyやチャフ、港に落ちているクレートといった物体を検知し、それら物体までの距離と、レーダーから見た方位を取得することができます。
現行のレーダーには、距離に±1%、方位に0.001[turn]のノイズが掛かっていますが、カルマンフィルタなどのフィルタ技術が広まってきたこともあり、物体追尾・運動計算の精度は近年格段に向上しました。
ですが、埼玉地上軍がここ一年顔を出してきた「センシャドー」のようなマルチ会ではレーダーの搭載・使用が禁止されていることがあります。
また、センサーが地上/空中にあり、目標が海中にある場合、またその逆など、Stormworksの仕様による制限から目標追尾にレーダーを使用できないケースが存在するのも事実です。
そこで、レーダーに依存せず目標の位置と運動を計算するために、レーザーによる目標追尾を実現しました。
理論と着想
《Parallel Kozak II》光学追尾装置は、2基~12基のLaser Distance Sensorを使い、非線形カルマンフィルタを用いて静止・移動目標を追尾する装置です。
カルマンフィルタはノイズのある入力値から確率的に真値を推定する数学的装置で、これをレーザーによる追尾にも応用しました。
レーザーの指向にノイズを加え、目標を包み込むようにレーザーを照射して、目標に命中したであろうレーザーからのみサンプルを得て、カルマンフィルタに入力します。
目標に命中したかどうかの区別は、フィルタの状態からマハラノビス距離を計算することと、命中した面の傾き(=法線)を計算し、水平に近い面は排除することで行っています。
傾きは、レーザーを2基1組とし、ほとんど水平にレーザーを照射、それぞれの命中点の水平距離と垂直距離を出すことで得ています。
命中点の傾きを得ることで、目標と地面とを区別し、地面に引っ張られることなく目標を追尾することが可能となりました。これが事実上の核となる着想だったと言えます。
実装
実装当時、ビークルLuaでは4096文字までのコードしか使えなかったため、カルマンフィルタ本体とレーザーの指向制御を分割せざるを得ませんでした。
以下はフィルタ本体のコードであり、Physics Sensor、コントローラからのBool信号、乱数のシード、そして最大12基のLaser Distance Sensorの出力を受取り、拡張カルマンフィルタによって目標の位置・速度・加速度を推定します。
-- Saitama Army "Parallel Kozak II" Laser Target Tracking Complex
i,o,p,m,t=input,output,property,math,table
IN,OB,ON,PN,TI,TR=i.getNumber,o.setBool,o.setNumber,p.getNumber,t.insert,t.remove
S,C,T,AS,AT,AB,SQ,pi,pi2=m.sin,m.cos,m.tan,m.asin,m.atan,m.abs,m.sqrt,m.pi,2*m.pi
function multiply(A,B,o,l)
if #A[1]~=#B then return nil end
o={}
for i=1,#A do
o[i]={}
end
for i=1,#A do
for j=1,#B[1] do
l=0
for k=1,#B do
l=l+A[i][k]*B[k][j]
end
o[i][j]=l
end
end
return o
end
function add(A,B,o)
o={}
for i=1,#A do
o[i]={}
for j=1,#A[1] do
o[i][j]=A[i][j]+B[i][j]
end
end
return o
end
function scl(a,B,o)
o={}
for i=1,#B do
o[i]={}
for j=1,#B[1] do
o[i][j]=a*B[i][j]
end
end
return o
end
function transpose(A,o)
o={}
for i=1,#A[1] do
o[i]={}
for j=1,#A do
o[i][j]=A[j][i]
end
end
return o
end
function inverse(A,o,e,m,n)
if #A~=#A[1] then return nil end
o,e=identity(#A),#A
for i=1,e do
m=A[i][i]
for j=1,e do
A[i][j]=A[i][j]/m
o[i][j]=o[i][j]/m
end
for k=1,e do
if i~=k then
n=A[k][i]
for j=1,e do
A[k][j]=A[k][j]-n*A[i][j]
o[k][j]=o[k][j]-n*o[i][j]
end
end
end
end
return o
end
function identity(n,o)
o={}
for i=1,n do
o[i]={}
for j=1,n do
o[i][j]=(i==j) and 1 or 0
end
end
return o
end
function convolution(A,o)
o={}
for i=1,9 do
o[i]={}
for j=1,9 do
o[i][j]=0
end
end
for i=0,2 do
for j=1,3 do
for k=1,3 do
o[j+i*3][k+i*3]=A[j][k]
end
end
end
return o
end
function rotationXYZ(e)
local cx,sx,cy,sy,cz,sz=C(-e[1]),S(-e[1]),C(-e[2]),S(-e[2]),C(-e[3]),S(-e[3])
return{{cy*cz,sx*sy*cz+cx*sz,-cx*sy*cz+sx*sz},
{-cy*sz,-sx*sy*sz+cx*cz,cx*sy*sz+sx*cz},
{sy,-sx*cy,cx*cy}}
end
function localfromRadar(o)
local d,a,e=o[1],o[2],o[3]
w=d*C(e)
return {w*S(a),d*S(e),w*C(a)}
end
function localtoWorld(o,r)
return transpose(multiply(rotationXYZ(r),transpose({o})))
end
function angleNormalize(a)
return(a%pi2+pi2*2.5)%pi2-pi
end
function BoxMuller(x,y)
return SQ(-2*m.log(x))*C(y*pi2)
end
function LCG(x,a,c,m)
a,c,m=1664525,1013904223,0x7FFFFFFF
x=(x*a+c)&m
return x,x/m
end
-- EKF
function Predict(x,P,Q,F,xp,pp)
xp=multiply(F,x)
pp=add(multiply(multiply(F,P),transpose(F)),Q)
return xp,pp
end
function Update(xp,pp,R,z,pos,jH,y,K,x,P,IKH)
jH=jhx(xp,pos)
y=add({z},scl(-1,{hx(xp,pos)}))
y[1][2]=angleNormalize(y[1][2])
y[1][3]=angleNormalize(y[1][3])
K=multiply(multiply(pp,transpose(jH)),inverse(add(multiply(multiply(jH,pp),transpose(jH)),R)))
x=add(xp,multiply(K,transpose(y)))
IKH=add(identity(n),scl(-1,multiply(K,jH)))
P=add(multiply(multiply(IKH,pp),transpose(IKH)),multiply(multiply(K,R),transpose(K)))
return x,P
end
function jhx(xp,P)
local x,y,z,p,q,r=xp[1][1]-P[1],xp[4][1]-P[2],xp[7][1]-P[3]
p=x^2+y^2+z^2
q=SQ(p)
return {
{x/q,0,0,y/q,0,0,z/q,0,0},
{1/(z*(x^2/z^2 + 1)),0,0,0,0,0,-x/(z^2*(x^2/z^2 + 1)),0,0},
{-x*y/(SQ(-y^2/(p)+1)*(p)^(3/2)),0,0,(-y^2/(p)^(3/2)+1/q)/SQ(-y^2/(p)+1),0,0,-y*z/(SQ(-y^2/(p)+1)*(p)^(3/2)),0,0}
}
end
function hx(xp,P)
local l,x,y,z,d,a,e={xp[1][1]-P[1],xp[4][1]-P[2],xp[7][1]-P[3]}
x,y,z=l[1],l[2],l[3]
d=SQ(x^2+y^2+z^2)
a=AT(x,z)
e=AS(y/d)
return {d,a,e}
end
function mahalanobissq(z,x,P,y,m)
y=add(z,scl(-1,transpose({{x[1][1],0,0,x[4][1],0,0,x[7][1],0,0}})))
m=multiply(multiply(transpose(y),inverse(scl(1,P))),y)
return m[1][1]
end
function NaNCheck(x) return x~=x and 0 or x end
dt=1/60
n=9
transition3x3={
{1,dt,dt^2/2},
{0,1,dt},
{0,0,0.95}
}
transition9x9=convolution(transition3x3)
sigma2Var=PN('Sigma2')
discreteWhiteNoise={
{dt^5/20,dt^4/8,dt^3/6},
{dt^4/8,dt^3/3,dt^2/2},
{dt^3/6,dt^2/2,dt}
}
vecQ=scl(sigma2Var,convolution(discreteWhiteNoise))
lDetected=false
mahaGate=PN('Maha Gate')
delay=5
B={}
noiseArray={}
function onTick()
pos={IN(1),IN(2),IN(3)}
rot={IN(4),NaNCheck(IN(5)),IN(6)}
int=string.unpack("I4",string.pack("f",IN(13)))
for i=0,31 do B[1+i]=(int&(1<<i))>0 end
ts=(int>>24)&255
targetSize=ts/2.0
ON(11,targetSize)
if B[5] and not B[13] then
-- laser noise
seedf=IN(14)
seed=string.unpack("I4",string.pack("f",seedf))
noise={}
for i=1,24 do
seed,out=LCG(seed)
TI(noise,out)
end
LOSDist=vecOut and hx(vecOut,pos)[1] or 100
noise.s=AT(targetSize/m.max((LOSDist),100))
TI(noiseArray,1,noise)
ON(14,IN(14))
ON(18,noise.s)
if #noiseArray>delay then
if not vecx then
-- init
-- need to delay
raw={IN(15),IN(16)/8*pi2,IN(17)/8*pi2}
l=localfromRadar(raw)
l=localtoWorld(l,rot)
lw=add(l,{pos})
lw={lw[1][1],0,0,lw[1][2],0,0,lw[1][3],0,0}
lw=transpose({lw})
vecOut=lw
if B[12] then
vecx=lw
vecP=scl(5,identity(9))
end
else
-- predict
lvecx,lvecP=vecx,vecP
vecx,vecP=Predict(vecx,vecP,vecQ,transition9x9)
-- calculate R
standardx,standardy=IN(19),IN(20)
deltay=AS((0.5-0.01)/LOSDist)/pi2
hit=0
for i=1,6 do
noise=noiseArray[delay+1]
worldPos,raw,l={},{},{}
for j=1,2 do
raw={IN(i*2+j+18),IN(19)+BoxMuller(noise[i*4-3],noise[i*4-2])*noise.s,IN(20)+BoxMuller(noise[i*4-1],noise[i*4])*noise.s/3-(j-1)*deltay}
l=localfromRadar(raw)
l=localtoWorld(l,rot)
l=add(add(l,{{i<4 and -0.25*(4-i) or 0.25*(i-3),0.5*j-0.75,0.75}}),{pos})
worldPos[j]=l
end
lnew=transpose({{l[1][1],0,0,l[1][2],0,0,l[1][3],0,0}})
rotraw=hx(lnew,pos)
-- gate function
-- maha distance
mahaDist=SQ(mahalanobissq(lnew,lvecx,lvecP))
-- pseudonormal
worldPosSub=add(worldPos[1],scl(-1,worldPos[2]))
pseudonormal=AT(worldPosSub[1][2],SQ(worldPosSub[1][1]*worldPosSub[1][1]+worldPosSub[1][3]*worldPosSub[1][3]))/pi2
vecR=scl(((noise.s)^2),identity(3))
vecR[1][1]=(LOSDist*0.02)^2/12
vecR[3][3]=vecR[2][2]/3
if raw[1]<4000 and mahaDist<mahaGate and AB(pseudonormal)>0.15 then
vecx,vecP=Update(vecx,vecP,vecR,rotraw,pos)
hit=hit+1
end
end
ON(13,hit)
vecOut=vecx
end
end
else
noiseArray={}
vecx,vecP,lvecx,lvecP=nil,nil,nil,nil
end
for i=1,12 do
ON(i+20,IN(i))
end
if vecOut then
for i=1,3 do
for j=1,3 do
ON((i-1)*3+j,vecOut[i+(j-1)*3][1])
end
end
end
end
また、下記はレーザー指向部のLuaです。
-- Saitama Army "Parallel Kozak" Laser Target Tracking Complex
i,o,p,m,t=input,output,property,math,table
IB,IN,OB,ON,PN,PB,TI,TR,TP,TU=i.getBool,i.getNumber,o.setBool,o.setNumber,p.getNumber,p.getBool,t.insert,t.remove,t.pack,t.unpack
S,C,T,AS,AT,AB,SQ,pi,pi2=m.sin,m.cos,m.tan,m.asin,m.atan,m.abs,m.sqrt,m.pi,2*m.pi
function generateMatrix(n,m,o)
o={}
for i=1,n do
o[i]={}
for j=1,m do
o[i][j]=0
end
end
return o
end
function multiply(A,B,o,l)
if #A[1]~=#B then return nil end
o={}
for i=1,#A do
o[i]={}
end
for i=1,#A do
for j=1,#B[1] do
l=0
for k=1,#B do
l=l+A[i][k]*B[k][j]
end
o[i][j]=l
end
end
return o
end
function add(A,B,o)
o={}
for i=1,#A do
o[i]={}
for j=1,#A[1] do
o[i][j]=A[i][j]+B[i][j]
end
end
return o
end
function subtract(A,B,o)
o={}
for i=1,#A do
o[i]={}
for j=1,#A[1] do
o[i][j]=A[i][j]-B[i][j]
end
end
return o
end
function scl(a,B,o)
o={}
for i=1,#B do
o[i]={}
for j=1,#B[1] do
o[i][j]=a*B[i][j]
end
end
return o
end
function transpose(A,o)
o={}
for i=1,#A[1] do
o[i]={}
for j=1,#A do
o[i][j]=A[j][i]
end
end
return o
end
function inverse(A,o,e,m,n)
if #A~=#A[1] then return nil end
o,e=identity(#A),#A
for i=1,e do
m=A[i][i]
for j=1,e do
A[i][j]=A[i][j]/m
o[i][j]=o[i][j]/m
end
for k=1,e do
if i~=k then
n=A[k][i]
for j=1,e do
A[k][j]=A[k][j]-n*A[i][j]
o[k][j]=o[k][j]-n*o[i][j]
end
end
end
end
return o
end
function identity(n,o)
o={}
for i=1,n do
o[i]={}
for j=1,n do
o[i][j]=(i==j) and 1 or 0
end
end
return o
end
function cross(A,B)
return {A[2]*B[3]-A[3]*B[2],A[3]*B[1]-A[1]*B[3],A[1]*B[2]-A[2]*B[1]}
end
function rotationX(r)
return {{1,0,0},{0,C(r),-S(r)},{0,S(r),C(r)}}
end
function rotationY(r)
return {{C(r),0,S(r)},{0,1,0},{-S(r),0,C(r)}}
end
function rotationZ(r)
return {{C(r),-S(r),0},{S(r),C(r),0},{0,0,1}}
end
function rotationXYZ(e)
return multiply(multiply(rotationX(-e[1]),rotationY(-e[2])),rotationZ(-e[3]))
end
function localfromRadar(o)
local d,a,e=o[1],o[2],o[3]
local w=d*C(e)
return {w*S(a),d*S(e),w*C(a)}
end
function angleNormalize(a)
return(a%pi2+pi2*2.5)%pi2-pi
end
function BoxMuller(x,y)
return SQ(-2*m.log(x))*C(y*pi2)
end
function magnitude(x,y,z)
return SQ(x^2+y^2+z^2)
end
function LCG(x,a,c,m)
a,c,m=1664525,1013904223,2147483647
x=(x*a+c)&m
return x,x/m
end
function hx(xp,P)
local l,x,y,z,d,a,e={xp[1][1]-P[1],xp[4][1]-P[2],xp[7][1]-P[3]}
x,y,z=l[1],l[2],l[3]
d=magnitude(x,y,z)
a=AT(x,z)
e=AS(y/d)
return {d,a,e}
end
function NaNCheck(x) return x~=x and 0 or x end
delay=5
langvel={0,0,0}
function onTick()
pos={IN(21),IN(22),IN(23)}
rot={IN(24),NaNCheck(IN(25)),IN(26)}
vel={IN(27),IN(28),IN(29)}
vel=transpose(multiply(transpose(rotationXYZ(rot)),transpose({vel})))
angvel={NaNCheck(IN(30)*pi2),NaNCheck(IN(31)*pi2),NaNCheck(IN(32)*pi2)}
targetPos={{IN(1),IN(2),IN(3)}}
targetVel={{IN(4),IN(5),IN(6)}}
targetPos=add(targetPos,scl(delay/60,targetVel))
targetPos=add(targetPos,scl(-delay/60,vel))
localPos=subtract(targetPos,{pos})
angvelvel=cross(angvel,TU(localPos))
angveldot=multiply({angvel},transpose({langvel}))
angvelgain=m.min(m.max(
angveldot[1][1]
/magnitude(angvel[1],angvel[2],angvel[3])
/magnitude(langvel[1],langvel[2],langvel[3])
,0.1),1)
localPos=add(localPos,scl(1*-delay/60,{angvelvel}))
localPos=multiply(rotationXYZ(rot),transpose(localPos))
seed=string.unpack("I4",string.pack("f",IN(14)))
noise={}
for i=1,24 do
seed,out=LCG(seed)
TI(noise,out)
end
noise.s=IN(18)
for i=0,6 do
if i>0 then
standardPos=transpose({{localPos[1][1],0,0,localPos[2][1],0,0,localPos[3][1],0,0}})
localDAE=hx(standardPos,{0,0,0})
ON(19+i*2,localDAE[2]+BoxMuller(noise[i*4-3],noise[i*4-2])*noise.s)
ON(20+i*2,localDAE[3]+BoxMuller(noise[i*4-1],noise[i*4])*noise.s/3)
else
standardPos=transpose({{localPos[1][1],0,0,localPos[2][1],0,0,localPos[3][1],0,0}})
localDAE=hx(standardPos,{0,0,0})
ON(19+i*2,localDAE[2])
ON(20+i*2,localDAE[3])
end
end
ON(18,localDAE[1])
langvel=angvel
end
性能
開発当初の要求性能は、
- 目標距離100m~4000mの範囲において、
- 20m/sの速度を発揮する、
- 全長5m~10m程度の車両を、
- 砲熕兵器の照準・ミサイルの誘導が可能な程度の正確さで、
追尾することでした。
では、チューン中の映像をご覧ください。目標はShairoさんが制作したチャレンジャー2、車体長8m程度で時速70kmを発揮します。
ご覧の通り、旋回・加減速をものともせず追尾し、少し不安定になっても再捕捉できていることがわかります。
また、車体が少々隠れてたとしても追尾が継続し、完全に隠れてしまったとしても位置を推定し続けます。
課題
そんな、レーダーの使えない状況でレーダーと同程度・それ以上に正確な目標追尾を提供してくれる《Parallel Kozak II》にも弱点があります。
まず、レーザーを用いているため遮蔽に弱く、目標が体を完全に隠してしまえば当然失探します。
失探してもこれまでの位置・速度などの情報からしばらくの間は現在位置を推定し続け、目標が体を出せば再捕捉することもありますが、これはあくまで副次的な機能です。
また、目標が急激な加減速・方向転換を行えば、フィルタの推定する現在位置と実際の位置が大きくズレます。
レーザーは推定位置に向けて照射されますから、結果としてレーザーが目標に命中せず、そうしてサンプルが入ってこなくなればそのまま失探するでしょう。
そして、目標と地面との区別を命中した面の傾斜を用いて行っている都合上、戦車の天板のように平たく地面に対して寝そべった面からはサンプルを得ることができません。
これによる悪影響は目標が坂を登っている・下っているときに顕著に現れます。車両の前面・側面からしかサンプルを取得できないので有効サンプル数が低下し、また推定位置もドリフトするでしょう。
より急な坂道では前面・側面すらも傾き、地面に対して垂直でなくなることにより、目標から有効なサンプルを取得できなくなります。
ここまでは追尾方式そのものに潜む問題ですが、兵装システムとして見たときの運用上の課題もあるでしょう。
目標位置の初期化を視線照準システムとレーザーによって行っている都合上、マルチ会のTPSと接続状況によっては初期化することすら困難なのです。
これはレーザーベースの目標捜索・捕捉システムを構築することによってのみ解決するでしょう。