使用開源工具進行 3D 數據可視化:VTK 使用教程
已發表: 2022-03-11在 Toptal 博客上的最新文章中,熟練的數據科學家 Charles Cook 寫了關於使用開源工具進行科學計算的文章。 他的教程重點介紹了開源工具以及它們在輕鬆處理數據和獲取結果方面可以發揮的作用。
但是一旦我們解決了所有這些複雜的微分方程,另一個問題就會出現。 我們如何理解和解釋來自這些模擬的大量數據? 我們如何可視化潛在的千兆字節數據,例如大型模擬中具有數百萬網格點的數據?
在我為碩士論文研究類似問題的過程中,我接觸到了可視化工具包(VTK)——一個專門用於數據可視化的強大圖形庫。
在本教程中,我將快速介紹 VTK 及其管道架構,並繼續討論使用葉輪泵中模擬流體數據的真實 3D 可視化示例。 最後我會列出圖書館的長處,以及我遇到的弱點。
數據可視化和 VTK 管道
開源庫 VTK 包含具有許多複雜可視化算法的可靠處理和渲染管道。 然而,它的功能並不止於此,隨著時間的推移,圖像和網格處理算法也被添加進來。 在我目前與一家牙科研究公司合作的項目中,我正在使用 VTK 在基於 Qt 的類似 CAD 的應用程序中執行基於網格的處理任務。 VTK 案例研究顯示了廣泛的適用應用。
VTK 的架構圍繞著一個強大的管道概念。 這個概念的基本輪廓如下所示:
- 資源處於管道的最開始,並創造“無中生有”。 例如,一個
vtkConeSource
創建一個 3D 錐體,一個vtkSTLReader
讀取*.stl
3D 幾何文件。 - 過濾器將源或其他過濾器的輸出轉換為新的東西。 例如,
vtkCutter
使用隱式函數(例如平面)在算法中切割前一個對象的輸出。 VTK 附帶的所有處理算法都以過濾器的形式實現,並且可以自由鏈接在一起。 - 映射器將數據轉換為圖形基元。 例如,它們可用於指定用於著色科學數據的查找表。 它們是指定顯示內容的抽象方式。
- Actor表示場景中的一個對象(幾何圖形加上顯示屬性)。 此處指定顏色、不透明度、陰影或方向等內容。
- 渲染器和窗口最終以獨立於平台的方式將渲染描述到屏幕上。
典型的 VTK 渲染管道從一個或多個源開始,使用各種過濾器將它們處理成多個輸出對象,然後使用映射器和演員分別渲染。 這個概念背後的力量是更新機制。 如果過濾器或源的設置發生更改,所有相關的過濾器、映射器、演員和渲染窗口都會自動更新。 另一方面,如果管道下游的對象需要信息以執行其任務,則它可以輕鬆獲取信息。
另外,不需要直接處理像OpenGL這樣的渲染系統。 VTK 以獨立於平台和(部分)渲染系統的方式封裝所有低級任務; 開發人員在更高的水平上工作。
帶有轉子泵數據集的代碼示例
讓我們看一個數據可視化示例,該示例使用 2011 年 IEEE 可視化競賽中旋轉葉輪泵中的流體流動數據集。數據本身是計算流體動力學模擬的結果,與 Charles Cook 的文章中描述的非常相似。
特色泵的壓縮模擬數據大小超過 30 GB。 它包含多個部分和多個時間步長,因此尺寸很大。 在本指南中,我們將使用其中一個時間步長的轉子部分,其壓縮大小約為 150 MB。
我選擇使用 VTK 的語言是 C++,但還有其他幾種語言的映射,如 Tcl/Tk、Java 和 Python。 如果目標只是單個數據集的可視化,則根本不需要編寫代碼,而是可以使用 Paraview,這是 VTK 大部分功能的圖形前端。
數據集以及為什麼需要 64 位
我從上面提供的 30 GB 數據集中提取了轉子數據集,方法是在 Paraview 中打開一個時間步並將轉子部分提取到一個單獨的文件中。 它是一個非結構化的網格文件,即由點和 3D 單元組成的 3D 體積,如六面體、四面體等。 每個 3D 點都有相關的值。 有時單元格也有關聯的值,但在這種情況下沒有。 該培訓將專注於點的壓力和速度,並嘗試在其 3D 環境中可視化這些。
壓縮文件大小約為 150 MB,使用 VTK 加載時內存大小約為 280 MB。 然而,通過在 VTK 中處理它,數據集在 VTK 管道中被多次緩存,我們很快就達到了 32 位程序的 2 GB 內存限制。 使用 VTK 時有一些方法可以節省內存,但為了簡單起見,我們將在 64 位中編譯和運行該示例。
致謝:數據集由德國克勞斯塔爾大學應用力學研究所提供(Dipl. Wirtsch.-Ing. Andreas Lucius)。
目標
我們將使用 VTK 作為工具實現的是下圖所示的可視化。 作為 3D 上下文,數據集的輪廓使用部分透明的線框渲染來顯示。 然後使用數據集的左側部分使用表面的簡單顏色編碼來顯示壓力。 (我們將跳過此示例中更複雜的體積渲染)。 為了可視化速度場,數據集的右側部分填充了流線,這些流線按照速度的大小進行了顏色編碼。 這種可視化選擇在技術上並不理想,但我想讓 VTK 代碼盡可能簡單。 此外,這個例子是可視化挑戰的一部分,即流動中的大量湍流是有原因的。
一步步
我將逐步討論 VTK 代碼,展示渲染輸出在每個階段的外觀。 完整的源代碼可以在培訓結束時下載。
讓我們首先從 VTK 中包含我們需要的所有內容並打開 main 函數。
#include <vtkActor.h> #include <vtkArrayCalculator.h> #include <vtkCamera.h> #include <vtkClipDataSet.h> #include <vtkCutter.h> #include <vtkDataSetMapper.h> #include <vtkInteractorStyleTrackballCamera.h> #include <vtkLookupTable.h> #include <vtkNew.h> #include <vtkPlane.h> #include <vtkPointData.h> #include <vtkPointSource.h> #include <vtkPolyDataMapper.h> #include <vtkProperty.h> #include <vtkRenderer.h> #include <vtkRenderWindow.h> #include <vtkRenderWindowInteractor.h> #include <vtkRibbonFilter.h> #include <vtkStreamTracer.h> #include <vtkSmartPointer.h> #include <vtkUnstructuredGrid.h> #include <vtkXMLUnstructuredGridReader.h> int main(int argc, char** argv) {
接下來,我們設置渲染器和渲染窗口以顯示我們的結果。 我們設置背景顏色和渲染窗口大小。
// Setup the renderer vtkNew<vtkRenderer> renderer; renderer->SetBackground(0.9, 0.9, 0.9); // Setup the render window vtkNew<vtkRenderWindow> renWin; renWin->AddRenderer(renderer.Get()); renWin->SetSize(500, 500);
使用此代碼,我們已經可以顯示一個靜態渲染窗口。 相反,我們選擇添加一個vtkRenderWindowInteractor
以交互地旋轉、縮放和平移場景。
// Setup the render window interactor vtkNew<vtkRenderWindowInteractor> interact; vtkNew<vtkInteractorStyleTrackballCamera> style; interact->SetRenderWindow(renWin.Get()); interact->SetInteractorStyle(style.Get());
現在我們有一個正在運行的示例,顯示一個灰色的空渲染窗口。
接下來,我們使用 VTK 附帶的眾多讀取器之一加載數據集。
// Read the file vtkSmartPointer<vtkXMLUnstructuredGridReader> pumpReader = vtkSmartPointer<vtkXMLUnstructuredGridReader>::New(); pumpReader->SetFileName("rotor.vtu");
簡短介紹 VTK 內存管理:VTK 使用一個方便的自動內存管理概念,圍繞引用計數。 然而,與大多數其他實現不同的是,引用計數保存在 VTK 對象本身中,而不是智能指針類。 這樣做的好處是可以增加引用計數,即使 VTK 對像是作為原始指針傳遞的。 有兩種主要的方法來創建託管的 VTK 對象。 vtkNew<T>
和vtkSmartPointer<T>::New()
,主要區別在於vtkSmartPointer<T>
可以隱式轉換為原始指針T*
,並且可以從函數返回。 對於vtkNew<T>
的實例,我們必須調用.Get()
來獲取原始指針,並且我們只能通過將其包裝到vtkSmartPointer
中來返回它。 在我們的示例中,我們從不從函數返回,並且所有對像都一直存在,因此我們將使用簡短的vtkNew
,只有上述例外用於演示目的。
此時,尚未從文件中讀取任何內容。 我們或更下游的過濾器必須調用Update()
才能真正發生文件讀取。 讓 VTK 類自己處理更新通常是最好的方法。 但是,有時我們希望直接訪問過濾器的結果,例如獲取此數據集中的壓力範圍。 然後我們需要手動調用Update()
。 (我們不會通過多次調用Update()
來降低性能,因為結果會被緩存。)
// Get the pressure range pumpReader->Update(); double pressureRange[2]; pumpReader->GetOutput()->GetPointData()->GetArray("Pressure")->GetRange(pressureRange);
接下來,我們需要使用vtkClipDataSet
提取數據集的左半部分。 為了實現這一點,我們首先定義一個定義拆分的vtkPlane
。 然後,我們將第一次看到 VTK 管道是如何連接在一起的: successor->SetInputConnection(predecessor->GetOutputPort())
。 每當我們從clipperLeft
請求更新時,此連接現在將確保所有前面的過濾器也是最新的。
// Clip the left part from the input vtkNew<vtkPlane> planeLeft; planeLeft->SetOrigin(0.0, 0.0, 0.0); planeLeft->SetNormal(-1.0, 0.0, 0.0); vtkNew<vtkClipDataSet> clipperLeft; clipperLeft->SetInputConnection(pumpReader->GetOutputPort()); clipperLeft->SetClipFunction(planeLeft.Get());
最後,我們創建我們的第一個演員和映射器來顯示左半邊的線框渲染。 請注意,映射器連接到其過濾器的方式與過濾器彼此連接的方式完全相同。 大多數時候,渲染器本身正在觸發所有actor、映射器和底層過濾器鏈的更新!
唯一不是不言自明的行可能是leftWireMapper->ScalarVisibilityOff();
- 它禁止通過設置為當前活動數組的壓力值對線框進行著色。
// Create the wireframe representation for the left part vtkNew<vtkDataSetMapper> leftWireMapper; leftWireMapper->SetInputConnection(clipperLeft->GetOutputPort()); leftWireMapper->ScalarVisibilityOff(); vtkNew<vtkActor> leftWireActor; leftWireActor->SetMapper(leftWireMapper.Get()); leftWireActor->GetProperty()->SetRepresentationToWireframe(); leftWireActor->GetProperty()->SetColor(0.8, 0.8, 0.8); leftWireActor->GetProperty()->SetLineWidth(0.5); leftWireActor->GetProperty()->SetOpacity(0.8); renderer->AddActor(leftWireActor.Get());
此時,渲染窗口終於顯示了一些東西,即左側部分的線框。

右側部分的線框渲染以類似的方式創建,通過將(新創建的) vtkClipDataSet
的平面法線切換到相反方向並稍微改變(新創建的)映射器和演員的顏色和不透明度。 請注意,這裡我們的 VTK 管道從同一個輸入數據集分成兩個方向(右和左)。
// Clip the right part from the input vtkNew<vtkPlane> planeRight; planeRight->SetOrigin(0.0, 0.0, 0.0); planeRight->SetNormal(1.0, 0.0, 0.0); vtkNew<vtkClipDataSet> clipperRight; clipperRight->SetInputConnection(pumpReader->GetOutputPort()); clipperRight->SetClipFunction(planeRight.Get()); // Create the wireframe representation for the right part vtkNew<vtkDataSetMapper> rightWireMapper; rightWireMapper->SetInputConnection(clipperRight->GetOutputPort()); rightWireMapper->ScalarVisibilityOff(); vtkNew<vtkActor> rightWireActor; rightWireActor->SetMapper(rightWireMapper.Get()); rightWireActor->GetProperty()->SetRepresentationToWireframe(); rightWireActor->GetProperty()->SetColor(0.2, 0.2, 0.2); rightWireActor->GetProperty()->SetLineWidth(0.5); rightWireActor->GetProperty()->SetOpacity(0.1); renderer->AddActor(rightWireActor.Get());
輸出窗口現在按預期顯示兩個線框部分。
現在我們準備可視化一些有用的數據! 為了將壓力可視化添加到左側部分,我們不需要做太多事情。 我們創建了一個新的映射器並將其連接到clipperLeft
,但這次我們通過壓力數組著色。 也是在這裡,我們最終利用了我們上面推導出的pressureRange
範圍。
// Create the pressure representation for the left part vtkNew<vtkDataSetMapper> pressureColorMapper; pressureColorMapper->SetInputConnection(clipperLeft->GetOutputPort()); pressureColorMapper->SelectColorArray("Pressure"); pressureColorMapper->SetScalarRange(pressureRange); vtkNew<vtkActor> pressureColorActor; pressureColorActor->SetMapper(pressureColorMapper.Get()); pressureColorActor->GetProperty()->SetOpacity(0.5); renderer->AddActor(pressureColorActor.Get());
輸出現在看起來如下圖所示。 中間的壓力非常低,將物料吸入泵中。 然後,這種材料被運送到外面,迅速獲得壓力。 (當然應該有一個帶有實際值的彩色地圖圖例,但我將其省略以使示例更短。)
現在更棘手的部分開始了。 我們想在右側繪製速度流線。 流線是通過在源點的矢量場內積分生成的。 向量場已經是“速度”向量數組形式的數據集的一部分。 所以我們只需要生成源點。 vtkPointSource
生成一個隨機點球體。 我們將生成 1500 個源點,因為它們中的大多數無論如何都不會位於數據集中,並且會被流跟踪器忽略。
// Create the source points for the streamlines vtkNew<vtkPointSource> pointSource; pointSource->SetCenter(0.0, 0.0, 0.015); pointSource->SetRadius(0.2); pointSource->SetDistributionToUniform(); pointSource->SetNumberOfPoints(1500);
接下來我們創建流跟踪器並設置其輸入連接。 “等等,多個連接?”,你可能會說。 是的 - 這是我們遇到的第一個具有多個輸入的 VTK 過濾器。 正常輸入連接用於向量場,源連接用於種子點。 由於“Velocity”是clipperRight
中的“活動”向量數組,我們不需要在這裡明確指定它。 最後,我們指定應該從種子點向兩個方向進行積分,並將積分方法設置為 Runge-Kutta-4.5。
vtkNew<vtkStreamTracer> tracer; tracer->SetInputConnection(clipperRight->GetOutputPort()); tracer->SetSourceConnection(pointSource->GetOutputPort()); tracer->SetIntegrationDirectionToBoth(); tracer->SetIntegratorTypeToRungeKutta45();
我們的下一個問題是通過速度大小為流線著色。 由於向量的大小沒有數組,我們將簡單地將大小計算到一個新的標量數組中。 正如您所猜到的,這個任務也有一個 VTK 過濾器: vtkArrayCalculator
。 它接受一個數據集並將其輸出不變,但只添加一個從一個或多個現有數組計算而來的數組。 我們將這個數組計算器配置為獲取“Velocity”向量的大小並將其輸出為“MagVelocity”。 最後,我們再次手動調用Update()
,以得出新數組的範圍。
// Compute the velocity magnitudes and create the ribbons vtkNew<vtkArrayCalculator> magCalc; magCalc->SetInputConnection(tracer->GetOutputPort()); magCalc->AddVectorArrayName("Velocity"); magCalc->SetResultArrayName("MagVelocity"); magCalc->SetFunction("mag(Velocity)"); magCalc->Update(); double magVelocityRange[2]; magCalc->GetOutput()->GetPointData()->GetArray("MagVelocity")->GetRange(magVelocityRange);
vtkStreamTracer
直接輸出折線, vtkArrayCalculator
將它們原封不動地傳遞。 因此,我們可以直接使用新的映射器和參與者顯示magCalc
的輸出。
相反,在本次培訓中,我們選擇通過顯示功能區來使輸出更好一些。 vtkRibbonFilter
生成 2D 單元格以顯示其輸入的所有折線的色帶。
// Create and render the ribbons vtkNew<vtkRibbonFilter> ribbonFilter; ribbonFilter->SetInputConnection(magCalc->GetOutputPort()); ribbonFilter->SetWidth(0.0005); vtkNew<vtkPolyDataMapper> streamlineMapper; streamlineMapper->SetInputConnection(ribbonFilter->GetOutputPort()); streamlineMapper->SelectColorArray("MagVelocity"); streamlineMapper->SetScalarRange(magVelocityRange); vtkNew<vtkActor> streamlineActor; streamlineActor->SetMapper(streamlineMapper.Get()); renderer->AddActor(streamlineActor.Get());
現在仍然缺少的,實際上也是生成中間渲染所需要的,是實際渲染場景和初始化交互器的最後五行。
// Render and show interactive window renWin->Render(); interact->Initialize(); interact->Start(); return 0; }
最後,我們得到了完成的可視化,我將在這裡再次展示:
可以在此處找到上述可視化的完整源代碼。
黃金三鏢客
我將列出我個人對 VTK 框架的優缺點列表來結束這篇文章。
優點:積極開發:VTK 正在由幾個貢獻者積極開發,主要來自研究社區。 這意味著一些尖端算法可用,許多 3D 格式可以導入和導出,錯誤被積極修復,問題通常在討論板上有現成的解決方案。
缺點:可靠性:然而,將來自不同貢獻者的許多算法與 VTK 的開放式管道設計相結合,可能會導致不尋常的過濾器組合出現問題。 為了弄清楚為什麼我的複雜過濾器鏈沒有產生預期的結果,我不得不多次進入 VTK 源代碼。 我強烈建議以允許調試的方式設置 VTK。
優點:軟件架構:VTK 的管道設計和通用架構似乎經過深思熟慮,並且很高興與之合作。 幾行代碼可以產生驚人的結果。 內置的數據結構易於理解和使用。
缺點:微架構:一些微架構設計決策超出了我的理解。 常量正確性幾乎不存在,數組作為輸入和輸出傳遞,沒有明顯區別。 我通過放棄一些性能並使用我自己的
vtkMath
包裝器為我自己的算法減輕了這一點,它利用了自定義 3D 類型,如typedef std::array<double, 3> Pnt3d;
.Pro : Micro Documentation : 所有類和過濾器的 Doxygen 文檔內容廣泛且可用,wiki 上的示例和測試用例對理解過濾器的使用方式也有很大幫助。
缺點:宏文檔:網絡上有幾個很好的 VTK 教程和介紹。 但是據我所知,沒有大的參考文檔來解釋具體的事情是如何完成的。 如果你想做一些新的事情,期望在一段時間內搜索如何去做。 此外,很難找到任務的特定過濾器。 但是,一旦找到它,Doxygen 文檔通常就足夠了。 探索 VTK 框架的一個好方法是下載並試用 Paraview。
優點:隱式並行化支持:如果您的源可以分成幾個可以獨立處理的部分,那麼並行化就像在處理單個部分的每個線程中創建一個單獨的過濾器鏈一樣簡單。 大多數大型可視化問題通常屬於這一類。
缺點:沒有顯式並行化支持:如果您沒有倖免於大的、可分割的問題,但您想利用多個內核,那麼您只能靠自己了。 您必須弄清楚哪些類是線程安全的,甚至可以通過反複試驗或閱讀源代碼來重新進入。 我曾經追踪到一個 VTK 過濾器的並行化問題,該過濾器使用靜態全局變量來調用某個 C 庫。
Pro : Buildsystem CMake : 多平台元構建系統 CMake 也是由 Kitware(VTK 的製造商)開發的,並在 Kitware 之外的許多項目中使用。 它與 VTK 很好地集成在一起,使得為多個平台設置構建系統變得不那麼痛苦。
Pro :平台獨立性、許可和長壽: VTK 是開箱即用的平台獨立性,並在非常寬鬆的 BSD 風格許可下獲得許可。 此外,對於需要專業支持的重要項目,我們還提供專業支持。 Kitware 得到了許多研究機構和其他公司的支持,並將持續一段時間。
遺言
總的來說,VTK 是解決我喜歡的各種問題的最佳數據可視化工具。 如果您遇到需要可視化、網格處理、圖像處理或類似任務的項目,請嘗試使用輸入示例啟動 Paraview,並評估 VTK 是否適合您。