1 前言
上週冒着零星小雨去附近的公園賞花,估計腦子裏多少進了一些雨水,以至於連 這樣的曲面是什麼樣子,都想象不出來了。無奈之下,只好跑去問女兒。彼時,她正在ipad上整理課堂筆記。我湊近瞄了一眼,瞬間感覺頭暈目眩,幾乎暈倒。這個課堂筆記,將數學的險惡展示得一覽無餘!
聽完我的問題,女兒笑了:用一方手帕表示這個曲面,手帕的左下角、右上角高高提起,左上角、右下角自然垂落,大概就是 的樣子。同號則大於零,異號則小於零,這麼簡單的問題,你都搞不懂,腦子是不是進水了?
天哪,腦子進水這事兒,居然被她猜到了!
“別亂說,下雨那天我打傘了。”
我一邊小聲反駁着,一邊落荒式地逃離了女兒的房間。
這事兒雖然有損於我在女兒心目中的光輝象形,倒也反映了一個事實:有些看似簡單的數學方程,卻可以構造出極其複雜的曲面或幾何體,如果沒有3D工具的輔助,即便腦子沒有進水,人類也很難憑空想象出它的樣子。另外,在學習或研究過程中,我們關注的數據往往會藏身於茫茫“數海”中,如果不借助於3D技術,我們很難想象它們是什麼樣子的,又是如何分佈的。
WxGL正是這樣一個用於應對上述需求的3D數據可視化工具,可以很方便地畫一些點面線體及其組合。關於WxGL,更多的信息請參考《開源我的3D庫WxGL:40行代碼將疫情地圖變成三維地球模型》。WxGL最初是我們的開發團隊自用的小工具,所以開源以後既沒有像樣的文檔,也沒有簡單的例子。本文就算是開源3D庫WxGL的demo吧,全部應用實例集成在一個腳本中,同時增加了3D系統信息顯示和位置姿態設置。本文僅對部分代碼做解讀,並沒有提供完整源碼。源碼已經更新到了GitHub,感興趣的同學可以去下載。
2 正弦曲線
繪製平面上的正弦曲線,首先要約定的值域範圍,從中(等距離)取出一定數量的點,計算各點對應的值。不用說,每個點對應的值一定是零。將各點、、拼合成頂點集,顏色集用的大小做映射,用drawLine()就可以輕鬆畫出正弦曲線了。
x = np.linspace(-2*np.pi, 2*np.pi, 1000)
y = np.sin(x)
z = np.zeros(1000)
v = np.dstack((x,y,z))[0]
c = self.cm.map(y, self.cm_curr, mode='RGBA')
self.master.drawLine('sin', v, c, method='SINGLE')
self.master.update()
顯示效果:
3 最簡單的曲面
在之間均勻取51個點,在之間均勻取51個點,生成和的網格,分別計算網格上每個點的和的積作爲,顏色集用的大小做映射,用drawMesh()繪製網格。
y, x = np.mgrid[-1:1:51j, -1:1:51j]
z = x*y
c = self.cm.map(z, self.cm_curr, mode='RGBA')
self.master.drawMesh('z=xy', x, y, z, c, mode=self.render)
self.master.update()
下圖使用了“前面顯示線條後面填充顏色”渲染效果。如果兩面都使用顏色或者線條,視覺效果會比較平淡。
4 稍微有點難度的曲面
在之間均勻取51個點,在之間均勻取51個點,生成和的網格,分別計算網格上每個點的和的和作爲,顏色集用的大小做映射,用drawMesh()繪製網格。
y, x = np.mgrid[-np.pi:np.pi:51j, -np.pi:np.pi:51j]
z = np.sin(x) + np.cos(y)
c = self.cm.map(z, self.cm_curr, mode='RGBA')
self.master.drawMesh('z=xy', x, y, z, c, mode=self.render)
self.master.update()
這個效果像不像一隻大水母?
5 無法憑空想象的曲面
在之間均勻取51個點,在之間均勻取51個點,生成和的網格,分別計算網格上每個點的作爲,顏色集用的大小做映射,用drawMesh()繪製網格。
x, y = np.mgrid[-2:2:50j,-2:2:50j]
z = 2*x*np.exp(-x**2-y**2)
c = self.cm.map(z, self.cm_curr, mode='RGBA')
self.master.drawMesh('z=xy', x, y, z, c, mode=self.render)
self.master.update()
下圖使用了“前面填充顏色後面顯示線條”渲染效果:
換個角度,換個ColorMap,看看效果:
兩面全用顏色試一試:
6 體數據
對於空間中的一個點,其座標爲,如果將 映射爲該點的顏色,則該顏色集就可以成爲體數據。
y, x = np.mgrid[-10:10:101j, -10:10:101j]
z = np.linspace(-10, 10, 101)
v = np.sin(z).repeat(101*101).reshape((101,101,101)) + np.sin(x) + np.sin(y)
c = self.cm.map(v, self.cm_curr, mode='RGBA')
self.master.drawVolume('volume', c, x, y, z, smooth=False)
self.master.update()
這是以原點爲中心的 的立方體,每一個點的顏色和 的對應關係如ColorBar所示。
但是,很多時候,我們更關心在這數據體內,某一類數據,比如說 的點有哪些?又是如何分佈的呢?很簡單,我們只需要把這些點之外的其他點的顏色的透明度置爲零,我們在視覺上就只會看到 的點了。
c = self.cm.map(np.where((v>-0.1)&(v<0.1), v, np.nan), self.cm_curr, mode='RGBA')
由於我們在空間中的選取的點不是連續的,因此,我們把 的條件改爲 ,顯示出來的結果是這樣的。
被剔除的另一部分是這樣的:
如果把篩選條件改爲 ,顯示出來的結果是這樣的。是不是感覺有點魔性呢?
如果把篩選條件改爲 ,顯示出來的結果是這樣的。感覺稍微正常了一點。
7 球和六面體的組合
在三維空間中生成一個球體表面上各個點的座標,需要藉助於參數方程。我們可以藉助於地球的經緯度概念,按照固定步長,經度從-180°變化到到180°,維度從-90變化到°到90°,就得到了經度和維度網格:
lat, lon = np.mgrid[-0.5*np.pi:0.5*np.pi:51j, -np.pi:np.pi:101j]
根據球體表面上每個點的經度緯度,很容易計算出每個點的空間座標:
z = np.sin(lat)
x = np.cos(lat)*np.cos(lon)
y = np.cos(lat)*np.sin(lon)
爲了讓球體表面顏色漂亮一點,我們用每一點上和的乘積映射顏色:
c = self.cm.map(x*y, self.cm_curr, mode='RGBA')
使用drawMesh()畫出這個網格:
self.master.drawMesh('ball', x, y, z, c=c, mode=self.render)
六面體相對簡單一些,我們可以分開畫六個面,每個面的顏色隨機生成:
v0, v1, v2, v3 = [1,1,-1], [-1,1,-1], [-1,-1,-1], [1,-1,-1]
v4, v5, v6, v7 = [1,1,1], [-1,1,1], [-1,-1,1], [1,-1,1]
bottom = np.array([v0, v3, v2, v1])*0.75
top = np.array([v4, v5, v6, v7])*0.75
front = np.array([v7, v6, v2, v3])*0.75
back = np.array([v4, v0, v1, v5])*0.75
right = np.array([v4, v7, v3, v0])*0.75
left = np.array([v6, v5, v1, v2])*0.75
self.master.drawSurface('cubo', bottom, c=np.random.random(3), mode=self.render)
self.master.drawSurface('cubo', top, c=np.random.random(3), mode=self.render)
self.master.drawSurface('cubo', front, c=np.random.random(3), mode=self.render)
self.master.drawSurface('cubo', back, c=np.random.random(3), mode=self.render)
self.master.drawSurface('cubo', right, c=np.random.random(3), mode=self.render)
self.master.drawSurface('cubo', left, c=np.random.random(3), mode=self.render)
用線條勾勒出的球和六面體:
用顏色表現出的球和六面體:
用“前面填充顏色後面顯示線條”的方式表現出的球和六面體:
8 地球
有了畫球的經驗,畫地球就輕車熟路了。唯一不同的是,球體表面每一個點的顏色,要對應到平面圖上。
# 從等經緯地圖上讀取經緯度網格上的每一個格點的顏色
c = np.array(Image.open('res/shadedrelief.png'))/255
# 生成和等經緯地圖分辨率一致的經緯度網格,計算經緯度網格上的每一個格點的空間座標(x,y,z)
lats, lons = np.mgrid[np.pi/2:-np.pi/2:complex(0,c.shape[0]), 0:2*np.pi:complex(0,c.shape[1])]
x = np.cos(lats)*np.cos(lons)
y = np.cos(lats)*np.sin(lons)
z = np.sin(lats)
self.master.drawMesh('earth', x, y, z, c)
self.master.update()
全球平面圖:
生成的地球效果:
9 三維重建
基於頭部CT斷層掃描圖片,可以完成頭部的三維重建。在這類,我使用了體數據繪製的方法。
# 讀取109張頭部CT的斷層掃描圖片
data = np.stack([np.array(Image.open('res/head%d.png'%i)) for i in range(109)], axis=0)
data = np.rollaxis(data, 2, start=0)[::-1] # 反轉數組軸(2軸變0軸),然後0軸逆序
# 三維重建(本質上是體數據繪製)
self.master.drawVolume('volume', data/255.0, method='Q', smooth=False)
self.master.update()
部分CT斷層掃描圖片:
三維重建後的效果如下圖。因爲斷層掃描不夠精細,也沒有插值,層與層之間的縫隙比較明顯。如果斷層數據足夠多,效果還可以更好一些。