Skip to content
All writing
Engineering · 2 min read

裁切、縮放、填滿:圖片處理的四堂課

一次 GIF 裁切裂成垃圾的 bug(Go + libvips 廣告服務),換來四堂圖片處理課:一張圖到底是什麼,以及動圖為什麼需要一條謹慎的載入路徑。

簡述

  • 情境
    • 我接手一個處理廣告素材的圖片服務(Go + libvips),要加上照參數做三種變換:裁切(crop)、縮放到指定尺寸(resize-to-fit)、填滿(fill)。上游會把 crop box、目標尺寸、填色一起傳下來,圖片系統把每張素材 render 成投放規格。但是在測試時,有一種素材:GIF 就會出現很大的裁切問題,所以引發了一連串更深入的程式實行面,「圖片」定義的了解。
上游參數(crop box + 目標尺寸 + 填色) 圖片處理服務 crop 定位 positional resize 均勻 uniform fill 定位 positional 輸出投放素材
一個 crop box、一個目標尺寸、一個填色——分給 crop、resize、fill,再 render 成投放規格。
  • Debug 思考脈絡

    • 三種變換寫完,拿一般 JPEG/PNG 測都好好的。等我丟一張動圖 GIF 進去,resize 出來的還會動,crop 跟 fill 卻整個裂掉,有時甚至只剩第一張、動畫不見了。
    • 一開始我以為是影格尺寸不一樣,或哪裡座標算錯。埋了 log 也看不出所以然。真正卡住我的,是我對「一張圖是什麼」的認知太天真:我以為圖片就是一張像素網格,crop、resize、fill 不過是算座標。
    • 挖進底層 C library: libvips 才發現,它把動圖存成一條「上下疊起來的長條」(page-strip),不是 N 張獨立影格;而且預設載入只讀第一頁,其餘影格連解碼都沒有。resize 安全、crop 會裂,差別只在變換有沒有看座標。
  • 動機

    • 與其每次遇到格式問題都用猜的,我想從「一張圖到底有哪些維度」重新學一次,把散落的知識收成一套心智模型,之後在程式「圖片」上的執行會有更深入的了解。
  • 最終目標

    • 同一條處理流程能同時正確吃靜圖跟動圖;選輸出格式時知道自己在權衡什麼;動畫壞掉時不會默默送出一張錯圖。

拒絕勝過默默給錯。 這次最痛的不是「crop 裂掉」,而是動圖載進來只剩第一張、沒有任何錯誤。程式繼續跑、輸出一張靜圖,沒人發現動畫掉了。工程上我一直記著一條原則:會產出「看似正常、其實錯」結果的路徑,寧可大聲失敗,也不要默默放行。所以後面我把 Export、Resize 的錯誤都往上拋,fill 少了 resize 也直接報,而不是吞掉。

反直覺的轉折:resize 安全,crop 卻裂掉

同一批變換,為什麼 resize 沒事、crop 跟 fill 會壞?關鍵只有一個問題:這個變換在乎像素在哪裡嗎?

  • resize 對每個像素做一樣的事,不管它在第幾格,縮整條長條、每格一起縮,動畫還活著。
  • crop、fill 針對特定座標或區域下手。在長條上那個座標指到的是「一堆影格疊起來的某個高度」,不是「每格的同一個位置」,一刀切下去就把相鄰兩格接在一起,變成垃圾。

這趟學習的 Roadmap

  1. 先打破「圖=像素網格」的錯覺,看清一張圖檔有哪些獨立維度。→ 一張圖不只是像素網格
  2. 把格式的取捨搞清楚:能力對照、大小怎麼來、投放情境怎麼選。→ 圖片格式速查表有損無損才是檔案大小關鍵廣告投放讓格式優先序翻轉
  3. 弄懂 libvips 怎麼存動圖,才知道 crop 為什麼會裂。→ libvips 把動圖存成一條上下疊起來的長條
  4. 修好載入這一步,讓靜圖動圖走同一條路。→ 一條載入路徑,同時吃靜圖與動圖
  5. 用「均勻 vs 定位」分清哪些變換能整條做、哪些要逐格。→ 均勻變換 vs 定位變換:在拼接長條上的差別

第 1 課:一張圖不只是像素網格

「圖就是一張畫面」這個模型,一碰真實檔案就破。一個圖檔其實同時帶著好幾個獨立維度:影格數、透明度、色彩儲存、有損無損、點陣或向量、位元深度、隱藏 metadata。每個維度都有一個會打臉天真假設的格式:JPEG 沒有 alpha、GIF 只有調色盤 256 色、GIF 每格的 delay 藏在 metadata 裡。處理前先問這張檔案用到哪些維度,比事後除錯省很多。

處理前先問一句:這張檔案用到哪些維度?

→ 深入:一張圖不只是像素網格(順帶補上格式功課:速查表有損無損廣告投放優先序

第 2 課:動圖是一條上下疊起來的長條

libvips 不會把動圖當成 N 張影格,而是把每一格上下疊成一張很高的圖(官方叫 page-strip,我都想成一捲攤開的衛生紙):5 格 × 100px 就是一張 500px 的圖。結構藏在 metadata:page-height(一格高)、n-pages(幾格)、delay(每格毫秒),而且 n-pages × page-height = 總高。所以 Height() 回的是整條的高,Pages() > 1 才是「我手上是一條長條、不是一張靜圖」的判斷依據。我原本猜影格尺寸各不相同,結果剛好相反:每格一模一樣大,正因為一樣大才能整齊疊成一條。

Pages() > 1 才是判斷動圖的依據,不是副檔名。

→ 深入:libvips 把動圖存成一條上下疊起來的長條

第 3 課:預設只讀第一頁,動畫就這樣默默消失

loader 的 n 預設是 1,丟一張 5 格 GIF 進去只會解第 0 頁,其餘 4 格根本沒解碼:沒有例外、沒有警告,就是安靜地掉了動畫、輸出一張靜圖。解法是載入時給 n=-1(govips 是 NumPages.Set(-1)),一次讀全部影格。這一步順便讓載入變成一條無條件的路徑:靜圖回 Pages()==1、動圖回 Pages()==5,靜圖傳 -1 也不會出錯,單張還是多張載完再用 Pages() 判斷就好。

預設值不是中性的,它會安靜地丟掉你沒讀到的影格。

→ 深入:一條載入路徑,同時吃靜圖與動圖

第 4 課:均勻變換能整條做,定位變換要逐格

回到那個裂掉的 crop。判斷一個變換能不能直接對整條長條做,只看它在不在乎座標:resize 這種對每個像素一視同仁的(均勻),縮整條就好;crop、fill 這種針對座標或區域的(定位),在長條上會跨過影格邊界,一定要 [n=-1] 載入、依 page-height 拆成每一格、每格套一樣的操作、再 vips_arrayjoin 接回去,最後把 page-heightn-pagesdelay 補回去。

縮放整條一起變 → 均勻,安全;針對座標下刀 → 定位,逐格處理。

→ 深入:均勻變換 vs 定位變換:在拼接長條上的差別

結尾

這四堂課其實是同一課:動圖不是一疊影格,是一張被疊起來的長圖;而「載進來」和「動手改」都得認得這件事。 resize 剛好對整條無害,才讓我一度以為問題只是座標算錯。

之後再碰圖片處理,我會先問自己:

  • 這條處理路徑,靜圖跟動圖是不是走同一條、載入時有沒有把影格全讀進來?
  • 我要做的變換會不會看座標?會的話,是不是還在對整條長條硬做?
  • 動畫或透明度掉了的時候,程式是大聲報錯,還是默默送出一張看起來沒問題的圖?
Tags #image-processing #libvips #go
// connect

Be brave | Be wise | Be grateful

21 BreakinCode

// elsewhere
LinkedInMedium (lang: en)Youtube
wh:~$William Hung· © 2026 Taipei · GMT+8 · Available for collaboration