課程入口 Teaching portalSpectraView 教學頁Orange 光譜 widgetsGitHub 原始碼
Hyperspectral Imaging × Chemometrics

NIR 高光譜影像的化學計量學實作

以巴塞隆納大學「Art Paint Pigment Concentrations」油畫顏料 NIR 高光譜影像資料集(240×240 像素 × 207 波長), 用 Python(Jupyter Notebook)Google ColabROrange Data Mining(HyperSpectra widget) 四種工具完成同一套分析:光譜前處理 → PCA 探索 → PLS 迴歸預測色素濃度 → 濃度分布影像。最後用互動測驗檢核你是否真的學會。

✏️ 直接挑戰互動測驗 📈 先看互動光譜
Dataset

1. 認識資料集:Art Paint Pigment Concentrations

這套資料由巴塞隆納大學 José F. García 教授與 Clarimma Sessa 提供(IASIM-10 工作坊資料集): 24 個塗在畫布上的油畫顏料樣品,各含已知比例的三種藍色色素—— 普魯士藍(Prussian blue)酞菁藍(Heliogen blue)群青(Ultramarine blue)——再加上油性展色劑(oil binder)。

項目內容
儀器BurgerMetrics HyperPro(推掃式 push-broom)
波長範圍988.9 – 1674.7 nm,207 個波長(約 3.3 nm 間距),屬近紅外(NIR)
影像大小馬賽克影像 240 × 240 像素,內含 24 個子區域(每樣品 40×60 像素),像素解析度 100 μm
資料立方(hypercube)240 × 240 × 207 → 攤平存成 (57600, 207) 矩陣
數值意義uint16 原始值 V 與反射率 R 的關係:V = R × 65536
檔案PaintCubeB.matPaintCubeC.mat(各含 PaintCubePaintMask)、PaintDemo.mat(裁切示範版 Cube A)、ArtImageY.mat(逐像素濃度 y)
y 變數yPigmentPercent:三色素占色素總量 %(和=100%);yTotalPercent:三色素+油占總重 %(和=100%)
任務用 A 組影像建模,預測 B、C 組影像的色素濃度,並討論空間變異
資料使用限制:此資料集原供 IASIM-10 工作坊教學使用;任何額外使用或成果發表須先取得 José F. García 教授(jfgarcia@ub.edu)或 James Burger(james.burger@burgermetrics.com)同意。本頁僅作課堂教學展示。
檔案格式注意:這些 .mat 檔內存的是 PLS_Toolbox 的 DSO(Dataset Object)struct, 不是單純矩陣。其中 PaintCubeA.mat 存成 MATLAB 物件(object),scipy.io.loadmat 讀不出來; 請改用 PaintDemo.mat 內的 PaintCubeA_ed(已轉成 struct 的裁切版),或直接用 PaintCubeB.mat / PaintCubeC.mat
24 個樣品的組成(TotalPercent,由 ArtImageY.mat 計算)
#Prussian %Heliogen %Ultramarine %Oil %
Interactive

2. 互動探索:先「看見」資料

以下圖表全部由真實資料計算後內嵌於本頁(Cube B 的 24 個樣品平均光譜與影像),可直接操作。

Cube B 平均波長影像
平均反射率影像(207 個波長取平均)。可看到 24 個樣品子區域的亮暗差異:含普魯士藍多的樣品在 NIR 吸收強、反射低。
PaintMask 樣品編號圖
PaintMask 樣品識別圖:每個像素標記屬於哪個樣品(1–24)。做監督式學習時靠它把像素對應到 y 值。
Cube C 普魯士藍濃度預測圖
PLS 預測的普魯士藍濃度圖(Cube C):用 Cube B 像素訓練、逐像素預測 Cube C,重新排回 240×240。這就是「化學影像(chemical imaging)」。

📈 24 個樣品的平均光譜(Cube B)

顯示: Y 軸:
普魯士藍系列(#1–8) 酞菁藍系列(#9–16) 群青系列(#17–24)

試著切到 log(1/R):吸收峰(如 ~1200 nm 附近 C–H 二倍頻、~1450 nm O–H)會變成「往上」的峰,且峰高與濃度更接近線性關係。

Chemometrics

3. 化學計量學原理:從光譜到濃度

不管你用 Python、R 還是 Orange,底層的化學計量學流程都一樣。四個工具只是同一條路的四種走法。

3.1 前處理:為什麼要 log(1/R)?

Beer–Lambert 定律說吸收度 A 與濃度 c 成正比(A = εlc)。反射式量測拿到的是反射率 R, 常用 A = log(1/R) 當「擬吸收度(apparent absorbance)」,讓訊號與濃度的關係更接近線性,利於之後的線性模型(PCA/PLS)。 其他常見前處理還有 SNV(標準常態變數,校正散射造成的乘法效應)、Savitzky–Golay 微分(去基線飄移、突顯峰形)等。

前處理不是越多越好!本頁實測:對 24 條平均光譜做 log(1/R)+SNV 之後跑 PLS 留一交叉驗證, RMSECV 從 4.9% 惡化到 38.6%——因為 SNV 把「整體吸收強度」這個與濃度直接相關的資訊也刮掉了一部分, 加上樣品 #17(唯一純群青樣品)被留出時模型必須外插,誤差爆炸。前處理要用驗證結果來選,不是套公式。

3.2 非監督探索:PCA

主成分分析(PCA)把 207 維光譜投影到少數幾個「最大變異方向」。分數圖(score plot)能一眼看出樣品分群、離群值與趨勢—— 但 PC 軸是「變異最大的方向」,不等於某個色素的濃度軸。

🧭 PCA 分數圖(24 條平均光譜,log(1/R),平均中心化)

滑鼠移到點上看樣品組成

3.3 監督式建模:PLS 迴歸

偏最小平方法(PLS regression)在壓縮 X(光譜)時同時考慮 y(濃度), 以少數幾個潛在變數(latent variables, LV)建立線性預測模型,是 NIR 定量的黃金標準。 LV 太少會欠擬合、太多會把雜訊學進去(過擬合),要用交叉驗證(cross-validation)選。

🎯 PLS 預測 vs. 實際(普魯士藍 %,留一交叉驗證)

虛線是 1:1 線。離線最遠的點就是 #17(純群青)——留一法把它留出時,訓練集中沒有任何「無普魯士藍且含群青」的樣品,模型只能外插。

3.4 驗證設計:為什麼 A 建模、B/C 驗證?

同一樣品的相鄰像素高度相關。如果把所有像素混在一起「隨機切」訓練/測試集, 測試集裡的像素跟訓練集裡的像素根本來自同一塊顏料——這是資料洩漏(data leakage),會嚴重高估模型表現。 正確做法是照資料集設計:用一次拍攝(Cube A 或 B)建模,用另一次獨立拍攝(Cube C)驗證,才能反映真實的預測能力。 本頁示範:Cube B 訓練 → Cube C 逐像素預測,24 樣品平均濃度的 RMSEP ≈ 3.6%

Toolchain 1

4. Python / Jupyter Notebook

最完整彈性的路線。需要 numpyscipyscikit-learnmatplotlib。 完整可執行的 notebook 在 examples/artpaint_pls.ipynb

4.1 讀取 DSO .mat 與還原影像

# pip install numpy scipy scikit-learn matplotlib
import numpy as np, scipy.io as sio
import matplotlib.pyplot as plt

m    = sio.loadmat("PaintCubeB.mat")
dso  = m["PaintCubeB"][0, 0]          # DSO struct(structured array)
X    = dso["data"].astype(float) / 65536.0   # V = R*65536 → 反射率 R,(57600, 207)
wl   = np.ravel(dso["axisscale"][1, 0])      # 207 個波長 988.9–1674.7 nm
mask = m["PaintMask"].astype(int)            # (240, 240),值 1–24

# MATLAB 是 column-major:像素列 ↔ 影像要用 order='F' 互轉
mean_img = X.mean(axis=1).reshape(240, 240, order="F")
plt.imshow(mean_img, cmap="viridis"); plt.title("mean reflectance"); plt.colorbar()

4.2 前處理與各樣品平均光譜

A = np.log10(1.0 / np.clip(X, 1e-6, None))     # log(1/R) 擬吸收度
mask_f = mask.reshape(-1, order="F")            # 與 X 的列對齊

S = np.array([A[mask_f == k].mean(axis=0) for k in range(1, 25)])  # (24, 207)
for s in S: plt.plot(wl, s)
plt.xlabel("wavelength (nm)"); plt.ylabel("log(1/R)")

4.3 y 值、PCA 與 PLS(留一交叉驗證)

from sklearn.decomposition import PCA
from sklearn.cross_decomposition import PLSRegression
from sklearn.model_selection import LeaveOneOut

ym   = sio.loadmat("ArtImageY.mat")
yTot = ym["yTotalPercent"][0, 0]["data"].astype(float)   # (57600, 4) 逐像素
y24  = np.array([yTot[mask_f == k].mean(axis=0) for k in range(1, 25)])
yP   = y24[:, 0]                                 # 第 0 欄 = Prussian %

scores = PCA(n_components=2).fit_transform(S - S.mean(axis=0))
plt.scatter(scores[:, 0], scores[:, 1], c=yP); plt.colorbar(label="Prussian %")

pred = np.zeros(24)
for tr, te in LeaveOneOut().split(S):
    pls = PLSRegression(n_components=5).fit(S[tr], yP[tr])
    pred[te] = pls.predict(S[te]).ravel()
rmsecv = np.sqrt(np.mean((pred - yP) ** 2))
print(f"RMSECV = {rmsecv:.2f} %")               # ≈ 4.9 %

4.4 逐像素預測 → 濃度分布圖(chemical image)

# 用 Cube B 的像素訓練,預測獨立拍攝的 Cube C
mC   = sio.loadmat("PaintCubeC.mat")
XC   = np.log10(65536.0 / np.clip(mC["PaintCubeC"][0, 0]["data"].astype(float), 1, None))

rng  = np.random.default_rng(0)
idx  = rng.choice(A.shape[0], 6000, replace=False)   # 抽 6000 個像素當訓練集就夠
pls  = PLSRegression(n_components=5).fit(A[idx], yTot[idx, 0])

pred_map = pls.predict(XC).ravel().reshape(240, 240, order="F")
plt.imshow(pred_map, cmap="inferno", vmin=0, vmax=35)
plt.colorbar(label="predicted Prussian %")
檢核點:把 Cube C 的預測值依 PaintMask 對 24 個樣品取平均,與已知濃度比較,RMSEP 應落在 3–5% 左右。若差很多,先檢查 order='F' 有沒有寫。
Toolchain 2

5. Google Colab:免安裝、雲端跑

程式碼與第 4 節完全相同(Colab 已內建 numpy/scipy/sklearn/matplotlib),差別只在「檔案怎麼進來」。.mat 檔共約 65 MB,建議放 Google Drive。

上傳 .mat 到
Google Drive
Colab 掛載 Drive
drive.mount
跑第 4 節程式
路徑改成 Drive 路徑
結果存回 Drive
# 方法一(推薦):掛載 Google Drive——檔案大、session 重啟不必重傳
from google.colab import drive
drive.mount("/content/drive")
DATA = "/content/drive/MyDrive/ArtImageDataA/"
m = sio.loadmat(DATA + "PaintCubeB.mat")

# 方法二:直接上傳(每次 session 重啟都要重傳,大檔不建議)
from google.colab import files
uploaded = files.upload()
Colab 小抄:① 免費版 RAM 約 12 GB,本資料集(57600×207 float64 ≈ 91 MB)完全沒問題; ② Shift+Enter 執行 cell;③ 執行階段 → 全部執行 可重跑整本; ④ 把第 4 節的 notebook 上傳到自己的 Drive 再用 Colab 開啟,即可直接使用。
Toolchain 3

6. R:R.matlab + pls 套件

R 生態的化學計量學主力是 pls(PLS/PCR)與 prospectr(光譜前處理)。讀 DSO .mat 用 R.matlab

# install.packages(c("R.matlab", "pls", "prospectr"))
library(R.matlab); library(pls)

m    <- readMat("PaintCubeB.mat")
dso  <- m$PaintCubeB[, , 1]              # DSO struct → list
X    <- dso$data / 65536                 # (57600, 207) 反射率
wl   <- as.numeric(dso$axisscale[[3]])   # 波長(cell {2,1};用 str(dso$axisscale) 確認位置)
mask <- m$PaintMask                      # 240×240
A    <- log10(1 / pmax(X, 1e-6))
maskf <- as.vector(mask)                 # R 本身就是 column-major,天生和 MATLAB 對齊!

# 各樣品平均光譜與 y
S   <- t(sapply(1:24, function(k) colMeans(A[maskf == k, ])))
ym  <- readMat("ArtImageY.mat")
yT  <- ym$yTotalPercent[, , 1]$data      # (57600, 4)
y24 <- t(sapply(1:24, function(k) colMeans(yT[maskf == k, ])))
yP  <- y24[, 1]                          # Prussian %

matplot(wl, t(S), type = "l", xlab = "wavelength (nm)", ylab = "log(1/R)")

# PCA 與 PLS(留一交叉驗證內建在 plsr)
pc  <- prcomp(S, center = TRUE)
plot(pc$x[, 1:2], col = "steelblue", pch = 19)

fit <- plsr(yP ~ S, ncomp = 8, validation = "LOO")
plot(RMSEP(fit))                          # 看 RMSECV vs LV 數,選 ~5 個 LV
plot(fit, ncomp = 5, line = TRUE)         # 預測 vs 實際

# 濃度分布圖:逐像素預測 Cube C 再排回 240×240
mC  <- readMat("PaintCubeC.mat")
AC  <- log10(65536 / pmax(mC$PaintCubeC[, , 1]$data, 1))
fitPix <- plsr(yT[idx, 1] ~ A[idx, ], ncomp = 5)   # idx = sample(57600, 6000)
predC  <- predict(fitPix, AC, ncomp = 5)
image(matrix(predC, 240, 240), col = hcl.colors(64, "Inferno"))
R 對 MATLAB 的先天優勢:R 的矩陣和 MATLAB 一樣是 column-major, as.vector(mask)matrix(pred, 240, 240) 直接就對齊,不像 Python 要記得 order='F'。 但 readMat 解析巢狀 cell 較囉嗦,建議先用 str(dso, max.level=1) 看結構。
Toolchain 4

7. Orange Data Mining:能用 HyperSpectra widget 嗎?

可以,而且 HyperSpectra 正是為這種資料設計的。 HyperSpectra widget 來自官方的 Orange-Spectroscopy 附加元件(add-on)(也是 Quasar 發行版的核心), 專門顯示高光譜影像:每一列(row)是一個像素的光譜,只要表格帶有 map_xmap_y 兩個空間座標 meta 欄位,它就能把像素排回影像、依任一波長或積分值上色,還能用滑鼠圈選 ROI 把選到的像素光譜送給下游 widget。

7.1 安裝

  1. Orange 選單 Options → Add-ons → 勾選 Spectroscopy → Install → 重啟 Orange;
  2. 或 pip 環境:pip install orange-spectroscopy
  3. 或直接安裝 Quasar(已把 Orange + Spectroscopy 打包好,光譜課首選)。

7.2 資料進 Orange:先轉檔

Orange 讀不了 PLS_Toolbox 的 DSO .mat(它的 .mat 支援僅限單純矩陣),所以先用我們提供的轉檔腳本 examples/artpaint_to_orange.py 把 hypercube 轉成 Orange 原生 .tab 表格——每列一個像素、207 個波長欄,外加 map_x/map_y/sample/Prussian/Heliogen/Ultramarine/Oil meta 欄:

# 在 ArtImageDataA 資料夾內執行;--stride 2 = 每 2 像素取 1(120×120,檔案小、載入快)
python artpaint_to_orange.py PaintCubeB.mat --stride 2 -o artpaint_B.tab
python artpaint_to_orange.py PaintCubeC.mat --stride 2 -o artpaint_C.tab

7.3 建議 workflow

File
artpaint_B.tab
HyperSpectra
看影像・圈 ROI
Preprocess Spectra
SavGol/Vector norm
PLS
target = Prussian
Test and Score
Cross validation
File
artpaint_C.tab
Predictions
套用 B 訓練的 PLS
HyperSpectra
把預測值畫回影像
  1. File 載入 artpaint_B.tab(轉檔腳本已把 Prussian 設為 target、map_x/map_y 設為 meta);
  2. HyperSpectra(Spectroscopy 分類下):自動用 map_x/map_y 排出 120×120 影像,左邊光譜、右邊影像,滑鼠框選區域即可比較不同樣品的光譜;
  3. Preprocess Spectra:試 Savitzky–Golay、Vector Normalization——記得用 Test and Score 比較前處理前後的 RMSE,呼應第 3.1 節「前處理用驗證選」;
  4. PLS(Model 分類下,Orange 3.32+ 內建):設定成分數(LV);Test and Score 看 cross-validation 的 RMSE/R²;
  5. artpaint_C.tab 接到 Predictions,再把輸出接回 HyperSpectra,選擇以預測欄位上色——濃度分布圖完成,跟 Python/R 的結果對照。
Orange 適合誰?完全不寫程式就能完成「載入 → 看影像 → 前處理 → PLS → 驗證 → 化學影像」整條流程, 很適合課堂第一次接觸;但要做客製化(例如自訂交叉驗證切法、逐像素大批預測)還是 Python/R 靈活。四種工具的定位: Orange=概念與直覺、Colab=零安裝上手、Jupyter=完整工作流、R=統計傳統與 pls 套件的嚴謹驗證輸出
Check Your Understanding

8. 互動測驗:你掌握了嗎?

10 題單選,涵蓋化學計量學原理工具操作兩類。 作答完按「交卷評分」,每題都會給解說;可重複作答。