vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

把十張裁到十六分之一解析度的縮圖,一張張塞進同一個漸進式 JPEG 的 scan 序列,Chrome 會照單全收地往下解——文章作者實測大約撐到第 90 張左右才放棄。這不是瀏覽器的 bug,是 JPEG 規格本身留下的合法縫隙。

Regressive JPEG:把整段動畫塞進一張漸進式 JPEG

進式 JPEG 原本要解決的問題很單純:讓圖片在還沒下載完的時候先給讀者看一張模糊但可辨識的預覽,細節再一批一批補上。maurycyz 這篇 side project 把「先粗後細」這個機制拆到最底層,發現漸進式編碼容許的其中一種合法狀態——只寫入 DC 係數的 scan——本身就是一張完整、解析度縮水但能獨立解出來的影像。把好幾張這種影像串成一個檔案,瀏覽器會照著漸進式解碼的邏輯把它們一張張疊上去,效果就是一支藏在單一 JPEG 檔案裡的逐格動畫。

多數瀏覽器內建的 JPEG 解碼器同時支援 baseline 與漸進式兩種模式,這是網頁生態多年來的共識,圖片庫、CDN、瀏覽器三邊都預期會遇到這兩種檔案。也正因為漸進式解碼是每個瀏覽器都得支援的基本能力,才有辦法把這篇文章講的手法直接丟給一般使用者的瀏覽器看,不需要外掛、不需要特殊 MIME type、甚至不需要對方知道這是張「動畫」。

播放/暫停/重置模擬疊格 · 最多 90 格

frame 0 / 90 · 1/16 解析度
模擬示意:每一格畫面都是一張獨立、合法的 DC-only scan,一格格覆寫前一格畫面。真實情況下瀏覽器多半在第 90 格前後停止繼續解碼。

模擬示意:每一格畫面都是一張獨立、合法的 DC-only scan,一格格覆寫前一格畫面

DC-only scan 逐格覆寫原圖,每格只有 1/16 解析度,多格疊起來就像動畫;Chrome 大約撐到第 90 格左右才放棄繼續解碼。

Scan:漸進式 JPEG 怎麼把一張圖切成好幾份

baseline JPEG 把每個 8x8 像素區塊做完 DCT(離散餘弦轉換)之後,一次把係數全寫進檔案。這些係數各自的頻率位置,解碼器裡叫「bin」:bin 0 是這個區塊的平均亮度或色度,也就是直流(DC);bin 1 到 63 依序是愈來愈高頻的細節,也就是交流(AC)。量化表把人眼不敏感的高頻 bin 壓得更粗,zig-zag 走位把同一區塊的 63 個 AC 係數按頻率排成一長串,方便後面的熵編碼(多半是 Huffman)把大量的零壓縮掉——這幾件事是 JPEG 格式本身的常識,不是這篇文章的發現,但理解它們才看得懂接下來的手法在鑽什麼縫。

熵編碼那一步,做的事是把一長串數字換成更短的位元序列:出現愈頻繁的數值(像一長串零)分到愈短的編碼,愈罕見的數值分到愈長的編碼,整體平均下來所需的位元數比固定長度編碼少。JPEG 標準允許用 Huffman 或算術編碼實作這一步,兩者原理相通,差別在於算術編碼理論上能逼近更接近熵極限的壓縮率,但實作與授權歷史更複雜,所以主流工具鏈預設仍是 Huffman。這段一樣是壓縮領域的常識背景,用來說明為什麼 zig-zag 排序要刻意把高頻係數集中在後段。

色版分開處理也不是這篇文章的發明,而是 JPEG 從一開始的設計:Y(亮度)、Cb、Cr(色度)三個色版各自做 DCT、各自量化,彼此互不影響。漸進式模式沿用這個分工,所以 scan 表裡才會看到「這個 scan 只碰 Y」「這個 scan 只碰 Cb」這種寫法——bin 範圍限制的是頻率,色版限制的是要處理哪個平面,兩者是分開的維度,一個 scan header 同時宣告這兩件事。

漸進式 JPEG 其實有兩種正交的漸進方式:一種是「光譜選擇」(spectral selection),也就是像 scan 表裡那樣,先送某個 bin 範圍、再送下一段 bin 範圍;另一種是「逐次逼近」(successive approximation),同一個 bin 範圍分好幾輪送,每輪只補幾個位元。範例裡的 10 個 scan 其實同時用了這兩招:bin 範圍先鋪滿(scan 0 到 5),再逐次逼近把精度堆滿(scan 6 到 9)。這組術語是 JPEG 規格本身的分類方式,不是這篇文章提出的框架,但拿來對照範例表,能更清楚看出十個 scan 為什麼分成兩個階段。

漸進式模式把「一次寫完」拆成好幾輪,每一輪叫一個 scan,各自有自己的 header,裡面寫明這一輪要處理哪些色版、哪個 bin 範圍。maurycyz 貼出他手上一張圖檔實際的 scan 表——10 個 scan,編號 0 到 9,每個 scan 各自涵蓋不同的色版組合、bin 範圍與位元精度。拖動下面的滑桿,可以照著這張表逐格看每個 scan 的 header 實際打開了什麼。

拖動滑桿瀏覽 10 個 scan · scan #0 - #9

#0
bin 0 bin 63 Y Cb Cr precision:Half(-1 bit)

scan #0 涵蓋 Y、Cb、Cr 三個色版,bin 範圍 0-0,precision 是 Half(-1 bit)——文章形容它「contains a very low resolution preview of the image」。

把表格從頭讀到尾,可以看出一個節奏:scan #0 先給三個色版各自的 DC 一個粗略值,這就是那張「很低解析度的預覽」;scan #1 開始碰 Y 版的低頻 AC;scan 2 到 5 把 Cb、Cr 的全部 AC 與 Y 版剩下的 AC 都用「half precision」寫完一輪——文章的說法是這幾個 scan「contain full low precision data」,也就是每個 bin 都有值了,只是精度還粗;scan 6 到 9 不再增加任何新的 bin,只是把已經寫過的 DC 跟 AC 逐一從 half 推到 full precision。這種「先把範圍鋪滿,再把精度堆高」的兩階段節奏,就是所謂的逐次逼近(successive approximation)——scan header 裡那組位元範圍,決定的正是「這一輪只補上第幾個位元」。

多輪 scan 換來的是「感知上更快看到內容」,不是「檔案更小」。同一張圖如果全部用 baseline 編碼,實際位元組數通常還比漸進式略少一點,因為漸進式每一輪都要重新起一個 scan header、重新跑一次熵編碼,這些 header 與重複的統計表都是額外開銷。瀏覽器願意吞下這個代價,圖的是使用者感受到「畫面多快開始有內容可看」,不是「下載完成的那一刻多快到」。這是漸進式格式從一開始就設定的取捨,不是這篇文章討論的重點,但理解這個取捨,才知道規格為什麼會留下「先給粗略預覽」這種模式,也才看得懂為什麼會有 scan #0 這種東西存在。

DC 與 AC 不能同時上場的規則

整份 scan 表背後有一條規格上的硬限制:「In progressive mode, a scan can't contain both AC (bins above 0) and DC (bin 0) data at the same time.」bin 0 跟 bin 1 到 63 不能出現在同一個 scan header 裡,這就是為什麼 scan #0 只寫 DC,scan #1 才開始碰 AC,而不是把 DC 跟低頻 AC 一次塞進第一個 scan。baseline 模式沒有這條限制,一個 scan 本來就同時含 DC 跟全部 AC,但 baseline 解碼器的行為是讀完唯一的那個 scan 就收工,並不存在「多讀幾個 scan」這種概念。

這條限制原本只是編碼規則裡一個不起眼的技術細節,但它剛好把「只寫 DC」逼成一個獨立、乾淨、有自己 header 的 scan,而不是被夾在其他資料中間、必須跟 AC 綁在一起才能存在。正是這個結構性的縫隙,讓接下來整套手法能夠成立——如果規格允許 DC 跟 AC 混在同一個 scan,DC-only 就不會是一種「單獨、可抽出來獨立使用」的合法狀態。

這條限制的背後,合理的推測是跟解碼器內部怎麼組織一張圖的係數矩陣有關:解碼器通常會先配置好整張圖每個色版、每個區塊的 64 個 bin,再依照收到的 scan 逐一填值。如果同一個 scan 可以混著填 DC 跟 AC,解碼器就得同時處理兩種完全不同的資料排列方式;把兩者分開,每個 scan 只做一件事,實作起來單純很多。這一段是對規格動機的推測,文章本身沒有解釋規格為什麼這樣設計,只陳述了「不能同時」這個事實本身。

DC-only:意外合法的最小 scan

只寫 bin 0 的 scan 解出來會是什麼樣子?不是一片純色的色塊。DCT 的運算單位是 16x16 區塊,所以只留 bin 0 解出來的每個區塊各自只剩一個數字——那個區塊的平均亮度或色度——但區塊跟區塊之間彼此不同,疊起來還是看得出原圖輪廓,只是解析度掉到十六分之一。文章直接把這種只含 scan #0 的檔案定義出來:「the smallest possible 'progressive' JPEG contains a single DC-only scan.」它完全符合規格,任何遵循規範的解碼器都吃得下,只是一般編碼器不會特別去產生這種刻意殘缺的檔案——它們的目標是把圖編完整,不是編出一張縮圖。

bin 0 跟 bin 1 到 63 在同一個 8x8 區塊裡分別對應什麼位置,是這裡的背景知識:DCT 把區塊轉成頻率係數之後,左上角那一格是直流,代表整塊的平均值;愈往右下角,對應的空間頻率愈高,代表的是區塊內部愈細碎的明暗變化。zig-zag 走位刻意不照著行列順序走,而是沿對角線來回,讓愈早出現的係數對應愈低的頻率——這樣量化之後常常出現一長串接近零的高頻係數,方便熵編碼把它們壓縮掉。下面這個小工具把 zig-zag 的走位路徑畫出來:左上角是 DC,一路蜿蜒到右下角的 bin 63。

點擊重播 zig-zag 走位動畫 · 64 個 DCT bin

DC 1 8 27 63

左上角 bin 0 是這個 8x8 區塊的直流平均值,愈往右下角頻率愈高。zig-zag 依序走過 64 個 bin,把同一區塊的係數排成一長串——這是 JPEG 熵編碼的背景知識,不是這篇文章的說法,但解釋了為什麼 scan header 可以用一個連續的 bin 範圍(像 1-5 或 6-63)來描述一批係數。

量化表是漸進式跟 baseline 共用的機制:每個 bin 除以一個量化步階、四捨五入,步階愈大、保留的細節愈粗。JPEG 品質設定調的正是這組步階——品質數字愈低,高頻 bin 的步階愈大,愈容易整個變成零。這也是為什麼一張圖只留 DC,看起來會是「糊」而不是「花」:量化捨棄的是高頻細節,不是整體亮度分布,區塊之間的明暗落差還在,只是每個區塊內部的紋理不見了。這幾句同樣是 DCT/JPEG 的背景知識,用來解釋現象,不是文章的論點。

用 jpegtran 手工刻一張 DC-only 影像

要拿到這種檔案不需要自己寫編碼器。文章給的做法是用 jpegtran 搭配一份 scan spec:

cat > frame.scans<<EOF
# DC only scan:
0,1,2:0-0,0,0;
# and nothing else
EOF
jpegtran -scans frame.scans -outfile out.jpg in.jpg

游標移到虛線詞彙上(或用 Tab 對焦)查看欄位說明

scan spec 那一行—— 0,1,2冒號前的數字是色版索引:0=Y、1=Cb、2=Cr,逗號分隔代表這三個色版都套用同一條規則。 :0-0冒號後第一組數字是這個 scan 涵蓋的 DCT bin 範圍:0-0 表示只寫 bin 0,也就是只寫 DC 係數,完全不含任何 AC。 、最後 ,0,0;最後兩個數字是逐次逼近(successive approximation)的位元範圍 Ah-Al,都寫 0 表示這個 scan 一次把 DC 寫到底,不再分批次逼近。 ——三段合起來就是「這個 scan 只含三個色版的 bin 0」,剛好對上 scan #0 的定義。

jpegtran 把這條規則套進既有的 JPEG,輸出的就是一張單一 scan、DC-only 的圖檔。整個過程不需要碰任何像素資料,也不需要重新做 DCT——jpegtran 本來就是設計來重組既有 JPEG 檔案內部結構的工具,這裡只是把它的能力用在「只留一種 scan」這個極端案例上。

jpegtran 之所以能做到這件事,是因為它操作的對象是已經編碼好的 DCT 係數,而不是原始像素——這類操作在 JPEG 生態裡統稱為無損轉換,常見用途是旋轉、裁切、重排 scan 順序,都不會經過反量化再重新量化這一步,所以不會多一次壓縮損失。這裡把它用在「只留一種 scan」,本質上跟裁切、旋轉是同一類操作,只是選的維度是 scan 結構而不是像素座標。

frame.scans 這份檔案其實可以連續寫很多行 scan 規則,每一行對應輸出檔案的一個 scan,jpegtran 讀到幾行就照順序寫幾個 scan header。這裡只用了一行,因為目標本來就是「只留一個 scan」;如果要手動重現文章列出的那張十個 scan 的範例表,理論上可以把十行規則依序寫進同一份 frame.scans,一次跑出完整的漸進式檔案。這是 jpegtran scan spec 語法本身的一般用法,用來幫助理解那唯一一行規則在整個語法裡的位置,不是文章示範過的操作。

串接與極限:把幀縫成一支動畫,撐到瀏覽器放棄

單一 DC-only 檔案只是素材。文章接下來做的事是:「Concatenate multiple images with the same resolution and filter out the start-of-image, start-of-frame and end-of-image markers.」把好幾張解析度一致的 DC-only 圖檔頭尾接在一起,同時把多餘的 SOI(圖檔開頭)、SOF(frame 起始)、EOI(圖檔結尾)marker 濾掉,只留下一份 SOI 開頭、一份 EOI 結尾,中間夾著一串本來各自獨立的 scan。

這整套手法能成立,某種程度上是因為 JPEG 從骨子裡就是一串自我描述的 marker:每一段資料前面都有一個 FF 開頭的 marker 標明接下來是什麼,解碼器靠 marker 分段讀,不是靠固定位移量或檔案總長度。也因為每個 marker 都是自我完備的區塊,直接把兩個檔案的 byte 首尾接起來、只挑要留的 marker,解碼器依然讀得懂——它看到的仍然是一串合法的 marker 序列,只是這串序列剛好比一般的漸進式 JPEG 多了幾個本來不該同時存在的 scan。這是 JPEG 容器格式的背景知識,文章沒有特別展開,但正是這個特性讓「串接、濾掉多餘 marker」這麼簡單就能生效。

拖動下方圓點檢視串接點 · 兩個檔案共 8 段 marker

File A 與 File B 串接後的位元組序列 SOI(A) 保留 SOF(A) 保留 scan A:DC-only 保留 EOI(A) 濾掉 SOI(B) 濾掉 SOF(B) 濾掉 scan B:DC-only 保留 EOI(B) 保留 目前:SOI(A) · 保留

解碼器看到的是一份漸進式 JPEG,裡面有很多個 scan——它不知道也不在乎這些 scan 原本來自不同的檔案、對應不同時間點的畫面,只會照著漸進式解碼的邏輯,把每個 DC-only scan 依序疊到畫面上。因為每個 scan 各自完整覆寫整張圖,而不是只補一小塊細節,疊上去的效果就是整格整格地換畫面——也就是動畫。

這支「動畫」能撐多長,不是規格說了算,是解碼器的實作細節說了算。文章給的實測數字是:「Doing this, I can get Chrome to render around 90 frames before giving up.」90 只是他測到的約略上限,不同瀏覽器、不同版本理論上不會一樣,JPEG 規格裡也沒有寫死一份檔案最多能有幾個 scan——這個數字是瀏覽器解碼器的容忍度,不是格式本身的邊界。

一個合理的推測是:解碼器內部大概對「這張圖已經處理過幾個 scan」設了某種上限或逾時保護,畢竟正常的漸進式 JPEG 很少會超過十幾個 scan,多數解碼器原本沒有理由為這種邊界情況做效能優化。90 這個數字聽起來像是撞到某種內部保護機制,而不是撞到記憶體或運算量的物理上限——但這只是根據現象做的推測,文章本身沒有解釋 Chrome 內部為什麼停在這裡。

拿這個手法跟 GIF、APNG 這類正牌動畫格式比,公平地說它並不實用:沒有影格間隔控制、沒有迴圈設定,動畫長度綁死在瀏覽器那個不透明的容忍上限上,換一顆解碼器行為就可能整個跑掉。它的價值不在於取代動畫格式,而在於示範規格裡的合法縫隙可以被組合到什麼程度——這是筆者自己的判斷,不是原文的主張。

對工程師來說,這個案例真正有用的地方不是「動畫塞進 JPEG」這個結果本身,而是背後的方法:先搞懂格式規格裡哪些狀態是合法但很少被用到,再看這些冷門合法狀態能不能被單獨抽出來、重新組合。漸進式 JPEG 的 scan 結構原本只是拿來做漸進式載入,DC-only scan 原本也只是這套機制裡的一個過渡狀態;只要規格沒有禁止把它單獨拿出來用、也沒有禁止把多個檔案的位元組直接串接,這個空間就一直開著,等著有人把它用在設計者沒想過的地方。

這手法能做到的事:把漸進式 JPEG「一個 scan 只能是 DC 或只能是 AC」這條限制,反過來當成畫格分隔器,用完全合規的 DC-only scan 疊出動畫效果,不需要任何官方格式擴充——代價是動畫能撐多久,得靠對著真實瀏覽器實測去撞牆,而不是靠規格保證。