專案實作

Article

風向資料傳輸轉圖片輸出

將高密度 NetCDF 風場資料映射到 PNG 色彩通道,降低前端解析、記憶體與網路傳輸的負擔。

GoOcean海洋遊憩風險資訊-圖台 」 工作時經手的其中一個專案 「Goocean」,承辦為國家海洋研究院,這專案目的是為了讓民眾可以進一步觀測海域從事活動、颱風預測、模擬預測、海岸資訊等等圖層工具來解台灣的海域狀況,而這專案技術原本是由.NET MVC 架構所開發的,但近期由我們公司開始將架構轉為前後端分離模式,後端一樣維持.NET,則前端轉為 React 專案,而我負責的部分也就是遷移至 React,除了圖片看到的圖台頁面其他的介紹頁面也是交由我從第一階段進行架構轉移。

問題解決

實際遷移後我發現其他圖層在 API 層面資料都以毫秒單位取得,只有「模擬預測」相關圖層( 紅色匡選內白色點 ) 是以秒為單位取得,研究了一下才知道這種資料格式稱作 「NetCDF」,在 GIS 應用也常見也常用於氣象學、大氣科學資料表示等等,這種資料本身是一種具有自描述性且能儲存多維度資料的格式,而自描述性如同字面資料除了包含多維度表示且會有描述性欄位也在裡面。 我認為這種以秒為單位取得資料的間隔且資料龐大本身對前端就是一種災難,第一點不外乎就是使用體驗,第二點來自於資料會儲存在瀏覽器記憶體,這種瞬間暴增的方式如果使用者裝置無法負荷就會照成閃退,這邊還沒考慮到 Parse 資料時也使用到的記憶體,後端的話我覺得就是網路頻寬,資料大小需要使用的傳輸頻寬也隨著變大 ( 帳單也隨著成長 )。 為了解決這問題我查了相關文章,有篇文章提到如何將維度資料映射到圖片的 RGB 從而傳輸,則前端 AJAX 其實是去下載一張外觀看起來像彩色雜訊的圖片,剛好完全符合我的需求,於是也與主管討論並交由我研究此項目,實作方式也就如同文章提到的,將向量資料寫入 RGB 後再由前端解析出數值。 解析NetCDF格式中多時空維度風場資料之展示方式 文章示意圖 文章示意圖

實作過程

我們就用 node.js 來實作生成的過程及前端解析過程, 整件事講白一點就是:把海流/風場的「方向和強度」這種浮點數字,塞進一張圖片的紅色和綠色通道裡傳給前端;前端再把顏色讀回來還原成數字,用來推動畫面上的粒子。 中間那張 PNG 不是給人看的,它只是一個免費又快速的壓縮容器。 之所以划算,是因為我們的網格是 376 × 426 = 160,176 個點,每個點還有東西向(U)和南北向(V)兩個數值:

  • 用 JSON 傳:每個浮點數寫成文字大約 18 個字元,光是文字就 2MB 起跳,瀏覽器還得用 JSON.parse 一個字一個字解析。
  • 用 PNG 傳:一個點壓成一個像素(4 bytes),壓縮後通常只剩一兩百 KB。 順帶的好處是,這張圖之後可以直接丟給 WebGL 當貼圖,要把粒子運算搬到 GPU 也不用改資料格式。

後端代碼 後端是一支很單純的 Express 服務,開兩個端點:/api/netcdf-info 回傳換算需要的 header,/api/netcdf-image 回傳編碼好的 PNG。核心只有兩個函式。

生成過程 拆成三個動作來看: 第一步,把 U 和 V 配對起來,順便量一下範圍。 原始 JSON 是一個陣列,裡面每一項是一個變數(wx / us 是東西向分量、wy / vs 是南北向分量)。程式先用 parameterNumberName 把這兩項找出來,逐點配成 [u, v],同時記下四個數字:minUmaxUminVmaxV。 這四個數字很關鍵,它們是解碼字典。圖片裡只存了「相對位置」,如果前端不知道 0~255 對應到什麼範圍,R=128 到底代表 0.3 還是 3.0 m/s 根本無從得知。所以才需要另外開一支 /api/netcdf-info 把它們送出去。 第二步,把數值攤平成 0~255 寫進像素。 用的是最單純的線性映射:

通道存什麼公式
R東西向分量 U(u - minU) / (maxU - minU) × 255
G南北向分量 V(v - minV) / (maxV - minV) × 255
B目前沒用,先留著0
A有沒有資料有資料 255,陸地 0
這裡的 Alpha 通道不是拿來做透明的,是被借用來當陸地遮罩。陸地上沒有流速,U 和 V 都是 0,就標成 A = 0,讓前端知道這一格要跳過。
第三步,交給 sharp 包成 PNG。 sharp(buffer, { raw: {...} }).png() 只是把這塊原始 RGBA 資料塞進 PNG 容器。這裡有個前提很重要:PNG 是無損格式,寫進去什麼 byte、讀出來就是什麼 byte。整套做法能成立完全靠這點,換成 JPEG 立刻報廢,因為 JPEG 的有損壓縮會把顏色改掉,等於把數值改掉。

前端代碼 前端是一個 NetCDFRenderer class,負責「讀圖 → 還原數值 → 跑粒子動畫」。以下是解析相關的核心片段。

解析過程 第一步,用 canvas 當開罐器。 瀏覽器沒有「直接讀 PNG 的像素」這種 API,標準做法是先把圖畫進一個看不見的 canvas,再用 getImageData 把像素撈出來。撈到的是一個 Uint8ClampedArray,四個一組平鋪:[R0, G0, B0, A0, R1, G1, B1, A1, ...]。 這裡有個容易踩到的坑:圖片是跨網域載入的,一定要設 crossOrigin = "anonymous",而且後端要回 CORS header。否則 canvas 會被標記為「被污染」,getImageData 會直接丟安全性錯誤。 第二步,把顏色換回數值。 就是後端公式的反運算,用的是 /api/netcdf-info 拿到的那組 min/max:

同時檢查 Alpha,是 0 就記成 null,代表這格是陸地。最後把一維像素陣列摺成二維的 grid[y][x],方便後面用座標取值。 第三步,用雙線性插值填補格子之間的空隙。 粒子在畫面上是連續移動的,它的位置幾乎不可能剛好落在網格點上。所以 getUV 會取周圍四個格子,依照粒子離每個角落的遠近算出加權平均——離哪個角落近,那個角落的影響就大。這樣流場看起來才會滑順,而不是一格一格跳。 第四步,粒子動畫。 每個粒子記著起點、終點、剩餘壽命和當下速度。每一幀做三件事:問流場「這裡該往哪走」、把粒子推一步、畫出這一步的線段。壽命歸零或飄出邊界就重新隨機投放,避免粒子全部堆積在同一處。 拖尾效果用的是 canvas 的合成模式技巧:每幀先用 destination-in 搭配 0.92 的透明度把整張畫布「乘上 0.92」,舊軌跡就會指數衰減成尾巴,而不是硬生生被清掉。

生成圖片 後端實際會產出兩種圖,用途完全不同: 一、除錯用的灰階圖。 每個變數各自輸出一張,把數值依 min/max 映射到 0~255,R = G = B。這種圖不參與任何運算,純粹是給人眼看的——可以直接確認資料長相正常(看得出海岸線、渦流結構),順便驗證「先經度再緯度」的排列方向有沒有搞反。資料轉換這種東西肉眼是最快的驗證方式。 二、真正上線用的 U + V 合成圖。 就是前面說的 R 存東西向、G 存南北向那張。它看起來會像一團彩色雜訊,完全看不出是海流圖,因為它本來就不是給人看的,是一個偽裝成圖片的數值容器。 最後補一個限制:8 bit 只有 256 階,換算解析度是 (max - min) / 255。以海流 ±1.5 m/s 為例,每一階大約是 0.0118 m/s。這個誤差就是為什麼前端要設 noDataThreshold = 0.02——把量化誤差造成的「假流速」濾掉,不然陸地邊緣會冒出一堆不該存在的粒子。如果之後需要更高精度,可以把目前閒置的 B 通道拿來當低位元組,做成 16 bit 編碼。

最終效益

看到下圖我們把之前直接傳輸 JSON 的方式替換成傳輸圖片格式,可以發現整個 API 取得時間整整下降了 x 倍。 todo

NetCDF 資料格式

NetCDF 本身是一種二進制資料,下方看到的是經由後端轉換過欄位也精簡過的樣子,可以看到 la1、lo1 表示的是網格範圍,這範圍所覆蓋的區域其實就是整個台灣區域,上方的展示圖片能看到整個底圖所覆蓋範圍。 NetCDF | NSF Unidata