同一顆 15px 的 icon,分別在 Firefox 與 Chrome 裡截圖、放大並排比對——瞇眼看,或是把畫面拉遠一點看,Chrome 那張的線條明顯粗了一圈。答案不在螢幕校色,也不在字型渲染,而藏在 JPEG 解碼流程裡一個容易被忽略的角落。
Chrome 只解了八分之一張圖?
有位工程師最早是在跟同事聊天、看對方電腦畫面時注意到這個差異——同一顆 15px 的 logo,在自己電腦上看起來卻不太一樣。後來要寫文章解釋這件事,因為那已經是很久以前的事了,於是重做了一張示範圖,放大後左右並排比對——左邊 Firefox,右邊 Chrome。瞇眼看,或是把畫面拉遠一點看,Chrome 那張的線條明顯粗了一圈,像是被誰悄悄加了一層極輕的膨脹濾鏡。這不是螢幕校色的問題,也不是字型渲染的問題,答案藏在 JPEG 解碼流程裡一個容易被忽略的角落:Chrome 用的 Skia 圖形引擎,遇到縮小顯示的 JPEG 時,未必會把整張圖完整解開再縮小。要理解這句話在講什麼,得先從 JPEG 壓縮本身怎麼切割一張圖說起。
頻域裡的 8×8 區塊
JPEG 壓縮的第一步,是把整張影像切成一格一格 8×8 像素的區塊,每個區塊各自轉換到頻域——這個轉換就是離散餘弦轉換,也就是 DCT。轉換完之後,一個區塊仍然是 8×8 個數字,只是這些數字的意義換了:左上角那一格代表整個區塊的平均亮度,越往右下角走,代表的空間頻率越高,對應的是區塊裡越細的明暗變化。邊界、紋理、突然的顏色轉折,多半藏在這些高頻係數裡;大片天空、一塊均勻的牆面,能量幾乎全部落在左上角那幾格。自然影像的能量大多集中在低頻,這也是 JPEG 能有效壓縮的前提——量化之後,右下角那堆高頻係數往往直接變成零,不需要花位元去記錄,壓縮率大半就是這樣賺回來的。
解碼時要做的是反過來的事:逆離散餘弦轉換,也就是 IDCT,把留下來的頻域係數還原回 64 個像素值。這件事有個常被忽略的性質——IDCT 不一定要用滿全部 64 個係數才能重建出一張看得懂的圖,係數用得越少,重建出來的畫面就越粗糙,但不是「壞掉」,而是缺了高頻細節的粗略版本。這正是後面整個故事的關鍵:如果最終根本不需要完整解析度,那何必把用不到的高頻係數也算出來?
文章沒有解釋為什麼「只算部分係數」還能得到有意義的結果,但一個合理的推測是:關鍵在於逆轉換本質上是一連串線性疊加。IDCT 把每一個頻域係數乘上對應的基底函式,再全部加起來,得到最終的像素值;係數與係數之間彼此獨立,加進來的每一項各自貢獻一部分能量,不需要先湊齊全部 64 項才能開始算。少算幾項,就是少加幾個疊加項,不影響其餘各項各自的正確性,只是最後加總出來的圖少了那幾項貢獻的高頻細節。可以合理推想,這個線性可分的性質,就是 partial IDCT scaling 之所以能夠成立的數學基礎——如果 IDCT 是那種「必須湊齊全部輸入才能算出任何一個輸出」的運算,這條捷徑根本無從談起。
這裡岔開一句背景——文章沒有提到量化,但這是 JPEG 標準本身的通識:量化這一步,是 JPEG 真正「有損」的地方,每一個頻域係數除以一個對應的量化步階,再四捨五入成整數,步階越大,能表示的層次越少;標準的量化表本來就替高頻位置配了比低頻位置更粗的步階,這也是一般情況下右下角的高頻係數容易被量化成零的原因。partial IDCT scaling 等於是站在這個基礎上更進一步:與其等量化步驟把高頻係數變成一堆接近零的小數字才丟棄,不如一開始就不去解碼、也不去計算它們,反正最後多半也用不到。
拖動滑桿保留的頻域係數數量 · 8×8 區塊逆轉換示範
左邊是只用保留下來的低頻係數重建的 8×8 區塊,右邊是留滿 8×8=64 個係數的完整逆轉換,當作對照組
拖曳滑桿只保留頻域左上角幾格係數,8×8 區塊逆轉換出的邊界會從一片均勻灰漸漸變回原本的硬轉折,對照原文說的『跳過高頻係數,只留粗略版本需要的部分』。
上面這個示範用了一個簡化的模型:一塊左暗右亮的 8×8 區塊,代表 icon 邊界常見的那種硬轉折。只留下最左上角那一個係數時,重建出來的是一片均勻的灰,邊界完全不見——因為那一格只記錄整個區塊的平均亮度;保留的低頻係數越多,重建出來的邊界就越接近原本的硬轉折,直到用滿 8×8 才幾乎重現原本的轉折位置。真正的 libjpeg-turbo 並不是逐格丟係數再做完整 IDCT,實際的縮放邏輯是直接算出縮小後的像素,運算量隨輸出尺寸一起縮小;但背後的直覺是一致的:省下的高頻係數越多,重建出來的邊界就越鈍。這個「越鈍」的效果,正是那顆 15px icon 看起來線條變粗的成因。
libjpeg-turbo 的 partial IDCT scaling
文中反覆出現幾個詞,先在這裡講清楚:頻域係數的位置左上角的係數代表低頻,也就是整個區塊的平均亮度;越靠右下角,代表的空間頻率越高,對應區塊裡越細的明暗變化與邊界。決定它記錄的是粗略色塊還是細節邊界;partial IDCT scalinglibjpeg-turbo 提供的功能:只用重建「粗略版本」所需要的低頻係數做逆轉換,不必先解出完整的 64 個係數再丟棄用不到的部分。則是本文要講的核心機制——縮放時直接跳過用不到的高頻係數。
完整解碼一張 JPEG,直覺的做法是先把整張圖在記憶體裡解壓回像素,再把這張大圖縮小到要顯示的尺寸——不管最後要顯示多小,都得先把每一個 8×8 區塊的 64 個係數完整算過一輪。Chrome 把圖片解碼與渲染委由 Skia 處理,Skia 對 JPEG 使用 libjpeg-turbo,而 libjpeg-turbo 實作了另一條路:partial IDCT scaling。與其解壓整張 JPEG,不如直接跳過高頻部分的係數,只用重建出「粗略版本」所需要的係數做逆轉換。這代表如果最終只需要顯示一張很小的圖,這個函式庫可以直接省掉大部分乘加運算,也不必為那些永遠不會被讀到的高頻細節配置記憶體——省下的運算和省下的記憶體,其實是同一件事的兩面:少算的係數,本來就不需要被存起來。
這條路徑不是 Chrome 特有的發明,而是把解碼責任下放給函式庫本身:呼叫端只要告訴 libjpeg-turbo「我想要多大的縮放比例」,剩下的取捨——該保留哪些係數、該跳過哪些運算——完全交給解碼器內部處理。問題也剛好出在這句「我想要多大的縮放比例」上:這個縮放比例,並不是想要多少就能給多少。
這種把縮放邏輯下放給解碼器的設計並不孤立,可以類比成其他場景裡類似的取捨:先問清楚呼叫端到底要多精確的答案,再決定要花多少運算去湊出那個答案。libjpeg-turbo 把這個問句限制成 8 個選項。代價是縮放比例的選擇權讓給了解碼器,呼叫端只能挑最接近的一檔,沒辦法要求剛好的任意比例。
SkJpegCodec.cpp 的門檻判斷
libjpeg-turbo 的 partial IDCT scaling 並不支援任意連續的縮放比例。Skia 原始碼裡的一句註解說得很直接:libjpeg-turbo 只支援八分之一、四分之一、八分之三、二分之一、八分之五、四分之三、八分之七跟原尺寸這 8 檔縮放,分母固定是 8。負責挑檔位的函式叫 onGetScaledDimensions,它要做的事,就是把呼叫端想要的縮放比例——原文裡叫 desiredScale,一個 0 到 1 之間的浮點數——量化成這 8 檔裡最接近的一檔。整段邏輯是一串由高到低的門檻判斷:想要的比例大於等於 0.9375,就用滿檔的原尺寸;大於等於 0.8125,退到八分之七;再往下每跨過一格門檻,就退一檔;低於 0.1875,不管原本想要多小,都只給最粗的八分之一。原文用一句話總結這個函式在做的事:它算出分母為 8、最接近目標值的那個分數,再把這個分數交給 libjpeg-turbo 做實際的縮放解碼。緊接著原文還交代了下一步:解碼出這個 n/8 的中間尺寸之後,Chrome 還會再用一個比較傳統的降採樣演算法,把圖進一步縮到真正需要的目標大小——n/8 這一步,只決定了「從 JPEG 解碼出來的中間尺寸」,不是畫面最終顯示的尺寸。
// libjpeg-turbo supports scaling by 1/8, 1/4, 3/8, 1/2, 5/8, 3/4, 7/8, and 1/1,
// so we will support these as well
unsigned int num;
unsigned int denom = 8;
if (desiredScale >= 0.9375) {
num = 8;
} else if (desiredScale >= 0.8125) {
num = 7;
} else if (desiredScale >= 0.6875f) {
num = 6;
} else if (desiredScale >= 0.5625f) {
num = 5;
} else if (desiredScale >= 0.4375f) {
num = 4;
} else if (desiredScale >= 0.3125f) {
num = 3;
} else if (desiredScale >= 0.1875f) {
num = 2;
} else {
num = 1;
}
分母選 8,文章沒有明講原因,但一個合理的推測是:JPEG 的區塊本身就是 8×8,DCT/IDCT 的運算天生就是以 8 為單位展開。要在這個 8 點的基底上做部分求和,最自然的切法就是照 1 到 8 這個範圍走——留 1 項、2 項,一路到留滿 8 項,每一檔都對應到基底裡一個乾淨的子集合,不需要額外的插值或近似。如果要支援任意分母,實作上就得處理係數個數對不齊基底的情況,複雜度會高出一截,換來的畫質差異在多數縮圖場景裡未必值得。
把這張門檻表直接套在文章自己舉的例子上——一張 2000×2000 的來源圖——可以把每一檔換算成實際的輸出尺寸,從最粗的八分之一檔一路排到滿檔的原尺寸:
點任一檔看它對應的門檻與輸出尺寸 · 8 檔
8 檔縮放對應到 2000×2000 來源圖的輸出尺寸
數字為套用 Skia 門檻表在 2000×2000 例子上的換算結果
desiredScale 這個數字從哪裡來,原文沒有細講,但可以合理推測:瀏覽器排版引擎知道這張圖最終會以多少 CSS 像素顯示、目前螢幕的 devicePixelRatio 是多少,兩者相乘、再除以來源圖的原始像素尺寸,就能算出一個介於 0 到 1 之間的縮放比例,交給 Skia 這一層去對應到最接近的 n/8 檔。這也代表同一張圖在不同裝置像素密度下——例如一般螢幕與 Retina 螢幕——可能會被量化到不同檔位,解出來的細節量因此也不一樣。
換句話說,desiredScale 這個連續的數字,最後只會落在 8 個離散的桶子裡;兩個很接近、但剛好落在門檻兩側的目標尺寸,會被 Skia 對應到完全不同的檔位,也可能是兩個明明不太一樣的目標尺寸,卻被量化到同一檔、拿到一模一樣的解碼結果。下面這個滑桿可以直接拖 desiredScale,即時看它會落在哪一檔:
拖動滑桿設定 desiredScale · 即時看 Skia 選中哪一檔
Skia 選中 n = 3 / 8(0.375)——來源 2000×2000 時,解碼輸出約 750×750 px。
精度讓給的是解碼速度與記憶體——縮放比例被量化成 8 檔,libjpeg-turbo 才能用固定的一組表格運算取代任意比例的重新採樣。對正在寫圖片處理服務的工程師來說,這張門檻表也是除錯時的參考:如果自己送進 Skia 的縮放請求卡在門檻邊界附近,兩次幾乎一樣的請求可能被導向不同檔位,畫質看起來會有微妙落差;反過來說,如果目標尺寸已經確定不會再變,直接檢查它落在哪一檔,就能預估解碼會保留多少高頻資訊,不必臨場去猜。這筆帳有多划算,換成實際的記憶體數字更直觀。
記憶體帳本:12 MB 對 1.2 KB
partial IDCT scaling 真正省下的是什麼?文章給了一個具體的假設情境:一張 2000×2000 的 JPEG,最終只需要顯示成 20×20。如果照直覺做法完整解壓,整張 bitmap 大約要吃掉 12 MB;但最後畫面上真正需要的 20×20 資料,大約只有 1.2 KB。兩個數字擺在一起,完整解壓消耗的記憶體大約是最終需要顯示的一萬倍——這一萬倍裡,絕大部分是永遠不會被畫到螢幕上的高頻細節,先解出來,再整批丟掉。
這裡也可以合理推想,雖然文章沒有細講:分配出來的 12 MB bitmap 不會憑空消失,得先把這塊記憶體準備好、寫滿,用完之後再釋放回去,這段配置與釋放本身就是額外成本——而以 12 MB 對 1.2 KB 的比例來看,裡面九成以上的內容從頭到尾不會被畫到螢幕上。少配置一塊用不到的記憶體,省下的可能不只是空間本身,也包含這段配置與釋放的額外開銷。
| 情境 | 解碼後 bitmap | 記憶體 |
|---|---|---|
| 完整解壓再縮小(直覺做法) | 2000×2000 | 約 12 MB |
| 畫面真正需要的資料 | 20×20 | 約 1.2 KB |
| 差距 | - | 約一萬倍 |
partial IDCT scaling 把這筆帳整個省掉:libjpeg-turbo 從一開始就只算需要的那個檔位,不會先把用不到的高頻係數解出來又扔掉。省下的不只是記憶體,還有做 IDCT 本身的乘加運算——原文的說法是,解壓縮本身會更快,因為跳過了一大截係數。這也是為什麼 Skia 願意接受「只能挑 8 檔、不能任意連續縮放」這個限制——用精度換速度,在縮圖這個場景裡划算。
文章舉的例子算的是一張圖,但可以合理推想,對於同時要渲染大量縮圖的頁面——商品列表、大頭貼牆、社群動態的縮圖流——這筆帳的意義更大。如果每一張縮圖都照「完整解壓再縮小」的路徑走,短時間內反覆配置又釋放記憶體,疊加起來就是頁面明顯的卡頓;partial IDCT scaling 把每張圖的成本從「解出一整份、丟掉大半份」,變成「只算自己需要的那一小塊」,張數一多,省下的總量會比單張圖片的差距更顯著。
對那顆 15px logo 意味著什麼
回到最開始那顆 15px 的 icon。15px 對應到來源圖的比例通常很小,desiredScale 很可能落在最低的那幾檔——很可能就是八分之一,也就是只用區塊裡極少數低頻係數重建出來的粗略版本。Firefox 走的路徑接近文章定義的「直覺做法」:完整解壓再縮小;Chrome 走的是 partial IDCT scaling,兩條路徑保留的高頻資訊量不一樣,重建出來的邊界粗細自然也不一樣——這是我從兩條路徑的差異推出的合理解釋,文章本身沒有明講 Firefox 內部具體怎麼實作,只交代了 Chrome 與 Skia 這一側的機制。作者給的結論很直接:JPEG 這個格式與它的最佳化手法,本來就是為相片設計的,不適合拿來做 icon 這類需要精確邊緣的小型資產。文章也提到,同樣的部分 IDCT 技巧反過來可以用在放大圖片上——那是另一個話題,但也說明了這個技巧本身是雙向的,既能省略運算做出粗略縮小版,理論上也能倒過來補足放大時缺的細節。
對日常要出圖的工程師來說,這件事的實用意義不在於「Chrome 有 bug」,而在於「兩個瀏覽器對同一張小尺寸 JPEG,本來就可能給出肉眼可辨的不同結果,這是解碼路徑的差異,不是誰壞掉了」。如果專案裡有需要在多種尺寸下維持銳利邊緣的小型資產——導覽列圖示、favicon、品牌 logo——選擇不需要頻域近似的格式,像 PNG 或 SVG,會比繼續用 JPEG 更保險;真的要用 JPEG,至少該預期它在極小尺寸下不會跟原圖長得一模一樣。
要確認自己在意的那張圖走了哪一條路,沒有捷徑,只能實際比較:把同一張小尺寸 JPEG 放進 Chrome 與 Firefox 分別截圖,肉眼比對邊緣的銳利程度,或是拉開瀏覽器的開發者工具檢查解碼後的實際像素。與其等使用者回報「你們的 icon 在 Chrome 上怪怪的」,不如在設計系統裡明訂小型資產一律用 PNG 或 SVG,從源頭把這個問題排除掉,不需要每個瀏覽器、每次改版都重新驗證一輪。
What this enables:知道 Chrome 的 JPEG 解碼在小尺寸時只挑 n/8 這幾檔、不是任意連續縮放,就能解釋「同一張小圖在不同瀏覽器長得不一樣」不是 bug,而是解碼路徑本身的差異;也代表要出精確邊緣的小型資產——icon、favicon、logo——PNG 或 SVG 會是比 JPEG 更保險的選擇。