圖片平均色的計算需要先決定兩件事:透明像素佔多少權重,以及 RGB 要在哪個色彩空間取平均。只把 8-bit R、G、B 相加再除以像素數,沒有處理這兩個條件。
以下函式計算的是「以 Alpha 為權重、在線性 sRGB 中取平均後,重新編碼成 sRGB 的顏色」。每個像素的面積相同。這個統計值不代表圖片中出現次數最多的顏色,也不預測人眼認定的主色。
先手動建立一個未預乘 Alpha 的 RGBA 陣列:
第一個像素:255, 0, 0, 0
第二個像素:0, 0, 255, 255
第一個像素完全透明,第二個像素是不透明的藍色。若直接平均 RGB,會得到接近紫色的數值,讓完全透明像素的紅色參與了結果。
這是用來測試演算法的合成資料,不表示 Canvas 一定會保留透明像素原本的 RGB。Canvas 的 Alpha 表示方式轉換可能損失資訊,不能依賴完全透明處的顏色被原樣讀回。
另一個例子是不透明的黑色與白色。直接平均 8-bit 數值會得到 127.5,四捨五入為 128。若先轉成線性 sRGB,兩者的通道值是 0 與 1,平均後為 0.5;重新編碼回 8-bit sRGB,得到的是 188。
128 與 188 對應不同的平均方式。先選定統計的定義,再用一致的輸入限制與預期結果測試函式。
sRGB 的 8-bit 通道值不是線性的光強度數值。以通道 c 為例,先計算 s = c / 255,再套用分段轉換:
s <= 0.04045:linear = s / 12.92
其他情況:linear = ((s + 0.055) / 1.055) ^ 2.4
接著將每個像素的 Alpha 正規化為 a = A / 255,計算三個通道的加權總和。以紅色通道為例:
平均線性紅色 = Σ(a × linearR) / Σa
平均 Alpha = Σa / 像素數
RGB 的分母使用 Alpha 總和,因此完全透明的像素不會改變平均 RGB。平均 Alpha 的分母仍然是全部像素數,透明區域會影響這個數值。若所有像素的 Alpha 都是 0,就沒有可回傳的代表色,函式回傳 null。
這裡的加權乘法是在統計時進行,不是要求把輸入的 RGB 預先乘過 Alpha。若把已預乘的資料送進同一個函式,會再次套用權重,得到不同的計算。
輸入限定為 8-bit、sRGB、未預乘 Alpha 的 RGBA 資料,型別為 Uint8ClampedArray。其他色域或浮點格式需要先處理,不能只換一個陣列型別就沿用。
將 256 個 sRGB 通道值先轉成查找表,迴圈內就只需查表和累加。結果最後才重新編碼與取整數,避免每個像素先取整數後再累加。
const SRGB_TO_LINEAR = Float64Array.from({ length: 256 }, (_, value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
function linearToSrgbByte(value) {
const l = Math.max(0, Math.min(1, value));
const s = l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055;
return Math.round(s * 255);
}
function averageVisibleSrgb8(data) {
if (!(data instanceof Uint8ClampedArray)) {
throw new TypeError('Expected an rgba-unorm8 Uint8ClampedArray');
}
if (data.length === 0 || data.length % 4 !== 0) {
throw new RangeError('Expected one or more complete RGBA pixels');
}
let red = 0;
let green = 0;
let blue = 0;
let alphaSum = 0;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3] / 255;
if (alpha === 0) continue;
red += SRGB_TO_LINEAR[data[i]] * alpha;
green += SRGB_TO_LINEAR[data[i + 1]] * alpha;
blue += SRGB_TO_LINEAR[data[i + 2]] * alpha;
alphaSum += alpha;
}
if (alphaSum === 0) return null;
return {
rgb: [red, green, blue].map(sum => linearToSrgbByte(sum / alphaSum)),
meanAlpha: alphaSum / (data.length / 4),
};
}
將函式放進 JavaScript 執行環境後,可以先用合成像素測試。這些資料的預期結果容易直接核對,也能分辨是透明度權重或色彩轉換出錯。
const transparentRedAndBlue = new Uint8ClampedArray([
255, 0, 0, 0,
0, 0, 255, 255,
]);
console.log(averageVisibleSrgb8(transparentRedAndBlue));
// { rgb: [0, 0, 255], meanAlpha: 0.5 }
const blackAndWhite = new Uint8ClampedArray([
0, 0, 0, 255,
255, 255, 255, 255,
]);
console.log(averageVisibleSrgb8(blackAndWhite));
// { rgb: [188, 188, 188], meanAlpha: 1 }
另加一組全透明資料,確認結果為 null;再把不透明藍色與 Alpha 為 128 的紅色放在一起,RGB 應為 [156, 0, 213]。這組資料可以檢查程式是否把所有非零 Alpha 都誤當成相同權重。
這個版本的純函式已在 Node.js 22.22.0 跑過測試,包括上面的案例、涵蓋各通道 0 至 255 的單像素案例、資料重複與重新排列,以及錯誤的輸入格式。這些測試驗證數值運算;瀏覽器解碼、跨來源權限和實際圖片的色彩轉換,仍是另外的整合範圍。
以下片段假設 canvas 已畫入完成解碼、且允許讀取像素的圖片,寬高也都大於 0:
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height, {
colorSpace: 'srgb',
pixelFormat: 'rgba-unorm8',
});
if (imageData.colorSpace !== 'srgb') {
throw new TypeError('Expected sRGB image data');
}
const result = averageVisibleSrgb8(imageData.data);
這段讀取方式需要瀏覽器支援對應的設定。若輸入是 Display P3 或浮點 HDR 資料,不能假裝它已符合 sRGB 8-bit 的輸入條件。跨來源圖片若使 Canvas 不再允許讀回像素,getImageData() 會拋出安全性例外,平均色函式也不會因此取得讀取權限。
函式會掃描全部像素,時間複雜度是 O(N),累加本身只需固定大小的額外空間。取得完整 ImageData 仍需要一份像素緩衝區;若圖片尺寸很大,要另外評估讀回與運算的成本。先縮圖再統計會改變輸入資料,不能把那個結果當作已經驗證過的全像素結果。
這個函式沒有選擇背景色。若需求是「合成到白底以後的平均色」,就應在指定的色彩空間中先完成合成,再對合成後的像素計算;用可見像素的平均 RGB 直接代替,會把透明區域的背景貢獻漏掉。
同樣地,圖片主色可能需要色彩分群,不能從單一平均值判定。保存結果時,一併記錄色彩空間、Alpha 權重和背景合成條件。比較兩次結果前,先核對這些設定是否相同。