[譯] 如何在無損的情況下讓圖片變的更小

Xat_MassacrE發表於2017-07-03

如何在無損的情況下讓圖片變的更小

Yelp(美國最大點評網站)已經有超過 1 億張使用者上傳的照片了,其中不但有晚餐、理髮等活動的照片還有我們的新特性照片 -- #yelfies(一種在拍攝時,加上自拍頭像的一種新的拍照方式)。這些圖片佔用了使用者 app 和網站的大多數頻寬,同時也代表著儲存和傳輸的巨大成本。為了給我們的使用者最好的使用者體驗,我們竭盡所能的優化我們的圖片,最終達到圖片大小平均減少 30%。這不僅節省了我們使用者的時間和頻寬,還減少了我們的伺服器成本。對了,關鍵的是我們的這個過程是完全無損的!

背景

Yelp 儲存使用者上傳的圖片已經有 12 年了。我們將 PNG 和 GIF 儲存為無損格式的 PNG,其他格式的儲存為 JPEG。我們使用 Python 和 Pillow 儲存圖片,讓我們直接從上傳圖片開始吧:

# do a typical thumbnail, preserving aspect ratio
new_photo = photo.copy()
new_photo.thumbnail(
    (width, height),
    resample=PIL.Image.ANTIALIAS,
)
thumbfile = cStringIO.StringIO()
save_args = {'format': format}
if format == 'JPEG':
    save_args['quality'] = 85
new_photo.save(thumbfile, **save_args)複製程式碼

下面讓我們來尋找一些可以在無損條件下優化檔案大小的方法。

優化

首先,我們要決定是選擇我們自己,還是一個 CDN 提供商 magically change 來處理我們的圖片。隨著我們對高質量內容的重視,評估各種方案並在圖片大小和質量之間做出取捨就顯得非常重要了。讓我們來研究一下當前圖片檔案減小的一些方法,我們可以做哪些改變以及每種方法我們可以減少多少大小和質量。完成這項研究之後,我們決定了三個主要策略。本文剩下的部分解釋了我們所做的工作,以及從每次優化中獲得的好處。

  1. Pillow 中的改變
  • 優化 flag
  • 漸進式 JPEG
  1. 更改應用的照片邏輯
  • 大 PNG 檢測
  • JPEG 動態質量
  1. 更換 JPEG 編碼器
  • Mozjpeg (柵格量化,自定義量化矩陣)

Pillow 中的改變

優化 Flag

這是我們做出的最簡單的改變之一:開啟 Pillow 中負責以 CPU 耗時為代價節省額外的檔案大小的設定 (optimize=True)。由於本質沒變,所有這對於圖片質量絲毫沒有影響。

對於 JPEG 來說,對個選項告訴編碼器通過對每個圖片進行一次額外的掃描以找到最佳的 霍夫曼編碼。第一次,不寫入檔案,而是計算每個值出現的次數,以及可以計算出理想編碼的必要資訊。PNG 內部使用 zlib,所以在這種情況下優化選項告訴編碼器使用 gzip -9 而不是 gzip -6

這是一個很簡單的改變,但是事實證明它也不是銀彈,因為檔案大小隻減少了百分之幾。

漸進式 JPEG

當我們將一張圖片儲存為 JPEG 時,你可以從下面的選項中選擇不同的型別:

  • 標準型: JPEG 圖片自上而下載入。
  • 漸進式: JPEG 圖片從模糊到清晰載入。漸進式的選項可以在 Pillow 中輕鬆的啟用 (progressive=True)。這是一個能明顯感覺到的效能提升(就是比起不是清晰的圖片,只載入一半的圖片更容易注意到。)

還有就是漸進式檔案的被打包時會有一個小幅的壓縮。更詳細的解釋請看 Wikipedia article,JPEG 格式在 8x8 畫素塊上使用鋸齒模式進行熵編碼。當這些畫素塊的值被解壓並按順序展開時,你會發現通常情況下非零的數字會優先出現,然後是零的序列,那個模式會對圖片的每一個 8x8 的畫素塊進行隔行掃描。使用漸進編碼時,被解壓開的畫素塊的順序會逐漸改變。每個塊中較大的值將會在檔案中首先出現,(漸進模式載入的圖片中區分度最高的區域將最早被掃描),而一段較長的小數字,包括許多數字零,將會在最末載入,用於填充細節。這種圖片資料的重新排列不會改變圖片本身,但是確實可能在某一行(這一行可以被更容易的壓縮)中增加了 0 的數量。

一個美味的甜甜圈的圖片的對比(點選放大):

A mock of how a baseline JPEG renders.
A mock of how a baseline JPEG renders.

模擬標準 JPEG 圖片的渲染效果。

A mock of how a progressive JPEG renders.
A mock of how a progressive JPEG renders.

模擬漸進式 JPEG 圖片的渲染效果。

更改應用的照片邏輯

大 PNG 檢測

Yelp 為使用者上傳的圖片主要提供兩種格式 - JPEG 和 PNG。JPEG 對於照片來說是一個很棒的格式,但是對於高對比度的設計內容,類似 logo,就不那麼優秀了。而 PNG 則是完全無損的,所以非常適用於圖形型別的圖片,但是對於差異不明顯的圖片又顯得太大了。如果使用者上傳的 PNG 圖片是照片的話(通過我們的識別),使用 JPEG 格式來儲存就會節省很大的空間。通常情況下,Yelp 上的 PNG 圖片都是移動裝置和 "美圖類" app 的截圖。

(left) A typical composited PNG upload with logo and border. (right) A typical PNG upload from a screenshot.
(left) A typical composited PNG upload with logo and border. (right) A typical PNG upload from a screenshot.

(左邊) 一張明顯的 PNG 合成圖。(右邊) 一張明顯的 PNG 的截圖。

我們想減少這些不必要的 PNG 圖片的數量,但重要的是要避免過度干預,改變格式或者降低圖片質量。那麼,我們如何來識別一張圖片呢?通過畫素嗎?

通過一組 2500 張圖片的實驗樣本,我們發現檔案大小和獨立畫素結合起來可以很好地幫助我們判斷。我們在最大解析度下生成我們的候選縮圖,然後看看輸出的 PNG 檔案是否大於 300KB。如果是,我們就檢測圖片內容是否有超過 2^16 個獨立畫素(Yelp 會將 RGBA 圖片轉化為 RGB,即使不轉,我們也會做這個檢測)。

在實驗資料集中,手動調整定義大圖片的數值可以減少 88% 的檔案大小(也就是說,如果我們將所有的圖片都轉換的話,我們預期可以節約的儲存空間),並且這些調整對圖片是無損的。

JPEG 動態質量

第一個也是最廣為人知的減小 JPEG 檔案大小的方法就是設定 quality。很多應用儲存 JPEG 時都會設定一個特定的質量數值。

質量其實是個很抽象的概念。實際上,一張 JPEG 圖片的每個顏色通道都有不同的質量。質量等級從 0 到 100 在不同的顏色通道上都對應不同的量化表,同時也決定了有多少資訊會丟失。
在訊號域量化是 JPEG 編碼中失去資訊的第一個步驟。

減少檔案大小最簡單的方法其實就是降低圖片的質量,引入更多的噪點。但是在給定的質量等級下,不是每張圖片都會丟失同樣多的資訊。

我們可以動態地為每一張圖片設定最優的質量等級,在質量和檔案大小之間找到一個平衡點。我們有以下兩種方法可以做到這點:

  • Bottom-up: 這些演算法是在 8x8 畫素塊級別上處理圖片來生成調優量化表的。它們會同時計算理論質量丟失量和和人眼視覺資訊丟失量。
  • Top-down: 這些演算法是將一整張圖片和它原版進行對比,然後檢測出丟失了多少資訊。通過不斷地用不同的質量引數生成候選圖片,然後選擇丟失量最小的那一張。

我們評估了一個 bottom-up 演算法,但是到目前為止,這個演算法還沒有在我們的實驗環境下得到一個滿意的結果(雖然這個演算法看上去在中等質量圖片地處理上還有不少發展潛力,因為處理中等質量圖片可以丟棄更多的資訊)。很多關於這個演算法的 學術論文 在 90 年代早期發表,但是在這個算力昂貴的時代,bottom-up 演算法的實現走了捷徑,比如沒有評估畫素塊之間的相互影響。

所以我們選擇第二種方法:使用二分法在不同的質量等級下生成候選圖片,然後使用 pyssim 計算它的結構相似矩陣 (SSIM) 來評估每張候選圖片損失的質量,直到這個值達到非靜態可配置的閾值為止。這個方法讓我們可以有選擇地降低檔案大小(和檔案質量),但是隻適用於那些即使降低質量使用者也察覺不到的圖片。

在下面的圖表中,我們畫出了通過 3 個不同的質量等級生成的 2500 張圖片的 SSIM 值的影像。

  1. 藍色的線為 quality = 85 生成的原始圖。
  2. 紅色的線為quality = 80 生成的圖。
  3. 最後,橘色的圖是我們最後使用的動態質量,引數為 SSIM 80-85。為一張圖片基於匯合點或者超過 SSIM 比率(一個提前計算好的靜態值,使得轉換髮生在影像範圍中間的某處)的地方在 80 到 85 (包括 85) 之間選擇一個質量值。這種方法可以有效地減小圖片大小,但是又不會突破我們圖片質量要求的底線。

SSIMs of 2500 images with 3 different quality strategies.
SSIMs of 2500 images with 3 different quality strategies.

2500 張 3 種不同的質量策略的 SSIM 值。

SSIM

這裡有不少可以模擬人類視覺系統的圖片質量演算法。在評估了很多方法之後,我們認為 SSIM 這個方法雖然比較古老,但卻是最適合對這幾個特徵做迭代優化的:

  1. JPEG 量化誤差敏感。
  2. 快速,簡單的演算法。
  3. 可以在 PIL 本地圖片物件上計算,而不需要將圖片轉換成 PNG 格式,而且還可以通過命令列執行(檢視 #2)。

動態質量的例項程式碼:

import cStringIO
import PIL.Image
from ssim import compute_ssim


def get_ssim_at_quality(photo, quality):
    """Return the ssim for this JPEG image saved at the specified quality"""
    ssim_photo = cStringIO.StringIO()
    # optimize is omitted here as it doesn't affect
    # quality but requires additional memory and cpu
    photo.save(ssim_photo, format="JPEG", quality=quality, progressive=True)
    ssim_photo.seek(0)
    ssim_score = compute_ssim(photo, PIL.Image.open(ssim_photo))
    return ssim_score


def _ssim_iteration_count(lo, hi):
    """Return the depth of the binary search tree for this range"""
    if lo >= hi:
        return 0
    else:
        return int(log(hi - lo, 2)) + 1


def jpeg_dynamic_quality(original_photo):
    """Return an integer representing the quality that this JPEG image should be
    saved at to attain the quality threshold specified for this photo class.

    Args:
        original_photo - a prepared PIL JPEG image (only JPEG is supported)
    """
    ssim_goal = 0.95
    hi = 85
    lo = 80

    # working on a smaller size image doesn't give worse results but is faster
    # changing this value requires updating the calculated thresholds
    photo = original_photo.resize((400, 400))

    if not _should_use_dynamic_quality():
        default_ssim = get_ssim_at_quality(photo, hi)
        return hi, default_ssim

    # 95 is the highest useful value for JPEG. Higher values cause different behavior
    # Used to establish the image's intrinsic ssim without encoder artifacts
    normalized_ssim = get_ssim_at_quality(photo, 95)
    selected_quality = selected_ssim = None

    # loop bisection. ssim function increases monotonically so this will converge
    for i in xrange(_ssim_iteration_count(lo, hi)):
        curr_quality = (lo + hi) // 2
        curr_ssim = get_ssim_at_quality(photo, curr_quality)
        ssim_ratio = curr_ssim / normalized_ssim

        if ssim_ratio >= ssim_goal:
            # continue to check whether a lower quality level also exceeds the goal
            selected_quality = curr_quality
            selected_ssim = curr_ssim
            hi = curr_quality
        else:
            lo = curr_quality

    if selected_quality:
        return selected_quality, selected_ssim
    else:
        default_ssim = get_ssim_at_quality(photo, hi)
        return hi, default_ssim複製程式碼

這裡有關於這項技術的其他的一些部落格,這篇 是 Colt Mcanlis 寫的。Etsy 也發表過一篇!快去看看吧!

更換 JPEG 編碼器

Mozjpeg

Mozjpeglibjpeg-turbo 的一個開源分支,是通過執行時間來置換檔案的大小的編碼器。這種方法完美的契合離線批處理再生成圖片。在比 libjpeg-turbo 多投入 3 到 5 倍的時間,和一點複雜的演算法就可以使圖片變的更小了!

mozjpeg 這個編碼器最大的不同點就是使用了一張額外的量化表。就像上面提到的,質量是每一個顏色通道量化表的一個抽象的概念。預設 JPEG 量化表的所有訊號點都十分容易被命中。用 JPEG 指導 中的話說就是:

這些表僅供參考,不能保證在任何應用中都是適用的。

所以說,大部分編碼器的實現預設情況下使用這些表就不足為奇了。

Mozipeg已經替我們掃平了使用基準測試選擇表的麻煩,並使用效能最好的通用替代方案建立圖片。

Mozjpeg + Pillow

大部分 Linux 發行版 都會預設安裝 libjpeg。所以預設情況下在 Pillow 中是無法使用 mozjpeg 的,但是配置好它並不難。當你要用 mozjpeg 編譯時,使用 --with-jpeg8 這個引數,並確認 Pillow 可以連結並找到它就可以了。如果你使用 Docker,你也可以像這樣寫一個 Dockerfile:

FROM ubuntu:xenial

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install \
    # build tools
    nasm \
    build-essential \
    autoconf \
    automake \
    libtool \
    pkg-config \
    # python tools
    python \
    python-dev \
    python-pip \
    python-setuptools \
    # cleanup
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Download and compile mozjpeg
ADD https://github.com/mozilla/mozjpeg/archive/v3.2-pre.tar.gz /mozjpeg-src/v3.2-pre.tar.gz
RUN tar -xzf /mozjpeg-src/v3.2-pre.tar.gz -C /mozjpeg-src/
WORKDIR /mozjpeg-src/mozjpeg-3.2-pre
RUN autoreconf -fiv \
    && ./configure --with-jpeg8 \
    && make install prefix=/usr libdir=/usr/lib64
RUN echo "/usr/lib64\n" > /etc/ld.so.conf.d/mozjpeg.conf
RUN ldconfig

# Build Pillow
RUN pip install virtualenv \
    && virtualenv /virtualenv_run \
    && /virtualenv_run/bin/pip install --upgrade pip \
    && /virtualenv_run/bin/pip install --no-binary=:all: Pillow==4.0.0複製程式碼

就是這樣!構建完成,你就可以在圖片處理工作流中使用帶有 mozipeg 的 Pillow 庫了。

影響

那麼這些方法到底帶來了多少提升呢?讓我們來研究研究,在 Yelp 的圖片庫中隨機抽取 2500 張圖片並使用我們的工作流來處理,看看檔案大小都有什麼變化:

  1. 更改 Pillow 的設定可以減小 4.5%
  2. 大 PNG 檢測可以減小 6.2%
  3. 動態質量可以減小 4.5%
  4. 更換為 mozjpeg 編碼器可以減小 13.8%

這些全部加起來可以讓圖片大小平均減小大概 30%,並且我們應用在最大最常見解析度的圖片上,對於使用者來說,不僅我們的網頁變的更快,同時平均每天還可以節省兆兆位元組的資料傳輸量。從 CDN 上就可見一斑:

Average filesize over time, as measured from the CDN (combined with non-image static content).
Average filesize over time, as measured from the CDN (combined with non-image static content).

CDN 上的時間變化與平均檔案大小的趨勢圖(包含非圖片的靜態內容)。

我們沒有做的

這一部分是為了介紹一些其他你們可能會用到的改善的方法,Yelp 沒有涉及到是因為我們選擇的工具鏈以及一些其他的權衡。

二次抽樣

二次抽樣 是決定網頁圖片質量和檔案大小的主要因素。關於二次抽樣的詳細說明可以在網上找到,但是對於這篇部落格簡而言之就是我們已經使用 4:1:1 二次抽樣過了(一般情況下 Pillow 的預設設定),所以這裡我們並不能得到任何提升。

有損 PNG 編碼

看到我們對 PNG 的處理之後,你可以選擇將一部分圖片使用類似 pngmini 的有損編碼器儲存為 PNG,但我們選擇把圖片另存為 JPEG 格式。這是另外一種不錯的選擇,在使用者沒有修改的情況下,檔案大小就降低了 72-85%。

動態格式

我們在正在考慮支援更多的新圖片型別,比如 WebP、JPEG2k。即使預定的專案上線了,使用者對於優化過的 JPEG 和 PNG 圖片請求的長尾效應也會繼續發揮作用,使得這一優化仍然是值得的。

SVG

在我們的網站上很多地方都使用了 SVG,比如我們的設計師按照風格指導設計的一些靜態資源。這種格式和類似 svgo 這樣的優化工具會顯著減少網頁的負擔,只是和我們這裡要做的工作沒什麼關係。

供應商的魔力

市面上有很多的供應商可以提供圖片的傳輸,改變大小,剪裁和轉碼服務。包括開源的 thumbor。或許對我們來說這是未來支援響應式圖片,動態格式和保留邊框最簡單方法。但是從目前的情況來看我們的解決方案已經足夠。

延伸閱讀

下面的這兩本書絕對有他們部落格中沒有提到的乾貨,同時也是今天這個主題強烈推薦的延伸閱讀書籍。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章