【圖像處理】圖像導(dǎo)出與工業(yè)級壓縮策略——從像素到文件的最后一公里

你處理好了圖像,現(xiàn)在需要把它保存成文件,或者上傳到服務(wù)器。
選 PNG 還是 JPEG?質(zhì)量參數(shù)設(shè)多少?如果文件太大怎么辦?
這一天我們來談?wù)劰I(yè)級項目里真正面對的問題。


一、PNG vs JPEG:格式選擇的決策樹

PNG(Portable Network Graphics)

  • 壓縮方式:無損壓縮(Deflate/LZ77)
  • 支持透明度:? 支持 Alpha 通道
  • 適合場景:截圖、UI 元素、圖標(biāo)、有透明區(qū)域的圖
  • 文件大小:比 JPEG 大 5~10 倍(對照片而言)
  • 質(zhì)量:無損,像素級完美還原

JPEG(Joint Photographic Experts Group)

  • 壓縮方式:有損壓縮(DCT + 量化)
  • 支持透明度:? 不支持(透明區(qū)域會被合成為白色)
  • 適合場景:照片、現(xiàn)實場景圖像
  • 文件大小:比 PNG 小 5~10 倍
  • 質(zhì)量:有損,quality 參數(shù)控制損失程度

決策樹

圖像有透明像素(Alpha < 255)?
    ├── 是 → PNG(必須,JPEG 會破壞透明)
    └── 否 → 需要無損存檔?
              ├── 是 → PNG
              └── 否 → JPEG(體積更小)
                        └── quality 如何選?→ 見下文

二、JPEG 的壓縮原理(概覽)

JPEG 之所以能大幅壓縮照片,依賴一個關(guān)鍵洞察:人眼對亮度細(xì)節(jié)敏感,對顏色細(xì)節(jié)不敏感。

JPEG 壓縮步驟

  1. 顏色空間轉(zhuǎn)換:RGB → YCbCr(Y=亮度,Cb/Cr=色差)
  2. 色度下采樣:Cb 和 Cr 通道降低到原來的 1/2(人眼感知不到)
  3. 分塊:將圖像分成 8×8 的小塊
  4. DCT(離散余弦變換):每個 8×8 塊轉(zhuǎn)換到頻域
  5. 量化:高頻系數(shù)(細(xì)節(jié))除以量化矩陣(有損!這步丟掉細(xì)節(jié))
  6. Huffman 編碼:量化后的系數(shù)做無損熵編碼

quality 參數(shù)影響量化步驟

  • quality 高(如 0.95)→ 量化矩陣系數(shù)小 → 高頻信息保留更多 → 文件更大
  • quality 低(如 0.5)→ 量化矩陣系數(shù)大 → 高頻信息大量丟棄 → 文件更小,有明顯塊狀偽影

三、quality 參數(shù)的選擇

沒有"最好的 quality",只有"滿足需求的 quality":

場景 推薦 quality 典型文件大?。?080p)
存檔備份 0.95+ 2~4 MB
高質(zhì)量分享 0.85~0.92 800KB~2MB
社交媒體 0.75~0.85 400~800KB
網(wǎng)頁縮略圖 0.60~0.75 200~400KB
極限壓縮 0.3~0.5 50~200KB(有明顯失真)

經(jīng)驗法則

  • quality 0.85 是"肉眼幾乎無損"和"體積合理"的黃金平衡點
  • 對于大多數(shù)照片,0.85 與 0.95 的視覺差異極小,但文件大小差異 50%+

四、內(nèi)容復(fù)雜度與動態(tài)質(zhì)量

固定 quality 對所有圖片用同一參數(shù),是粗糙的做法。工業(yè)級系統(tǒng)會根據(jù)圖像內(nèi)容復(fù)雜度動態(tài)調(diào)整質(zhì)量。

原理

  • 細(xì)節(jié)豐富的圖(風(fēng)景、人像、紋理):需要較高 quality 才能保留細(xì)節(jié)
  • 細(xì)節(jié)稀少的圖(純色、漸變、圖標(biāo)):低 quality 也看不出區(qū)別,不需要浪費空間

復(fù)雜度估算算法

// 計算相鄰像素的感知亮度差,作為復(fù)雜度指標(biāo)
func estimateQuality(for bitmap: MLBitmap, range: ClosedRange<CGFloat>) -> CGFloat {
    var totalDiff = 0
    var sampleCount = 0

    // 采樣:每隔 8 像素取一對相鄰像素
    for y in stride(from: 0, to: bitmap.height, by: 8) {
        for x in stride(from: 0, to: bitmap.width - 1, by: 8) {
            let i1 = bitmap.index(x: x,     y: y)
            let i2 = bitmap.index(x: x + 1, y: y)

            // BT.709 感知亮度(避免只看 R 通道導(dǎo)致藍(lán)/綠圖估算失準(zhǔn))
            let lum1 = (2126 * Int(bitmap.pixels[i1])   +
                        7152 * Int(bitmap.pixels[i1+1]) +
                         722 * Int(bitmap.pixels[i1+2])) / 10000
            let lum2 = (2126 * Int(bitmap.pixels[i2])   +
                        7152 * Int(bitmap.pixels[i2+1]) +
                         722 * Int(bitmap.pixels[i2+2])) / 10000

            totalDiff += abs(lum1 - lum2)
            sampleCount += 1
        }
    }

    let avgDiff = CGFloat(totalDiff) / CGFloat(sampleCount)
    let complexity = min(avgDiff / 30.0, 1.0)  // 歸一化

    // 低復(fù)雜度 → 區(qū)間下限質(zhì)量;高復(fù)雜度 → 區(qū)間上限質(zhì)量
    return range.lowerBound + complexity * (range.upperBound - range.lowerBound)
}

這是 Netflix 提出的 per-title encoding 思想在單幀圖像上的簡化應(yīng)用:不同內(nèi)容用不同參數(shù)編碼。


五、體積約束:二分法逼近

需求:"文件不能超過 3 MB"。挑戰(zhàn):quality 和文件大小之間的關(guān)系不是線性的,沒有公式直接算出"3 MB 對應(yīng) quality 多少"。

二分法(Binary Search) 解決這個問題:

func retryIfTooLarge(image: UIImage, maxBytes: Int, qRange: ClosedRange<CGFloat>) -> Data? {
    var lo: CGFloat = 0.3                    // 最低質(zhì)量
    var hi: CGFloat = qRange.upperBound      // 最高質(zhì)量
    var bestData: Data? = nil

    for _ in 0..<6 {        // 最多迭代 6 次,精度約 1%(2^6 = 64 個等分)
        let mid = (lo + hi) / 2
        guard let data = image.jpegData(compressionQuality: mid) else { continue }

        if data.count <= maxBytes {
            bestData = data  // 滿足約束,記錄,嘗試更高質(zhì)量
            lo = mid
        } else {
            hi = mid         // 不滿足約束,降低質(zhì)量
        }
    }

    return bestData
}

收斂過程示例

maxBytes = 1 MB

迭代 1:quality = 0.65,大小 = 1.5 MB > 1 MB → hi = 0.65
迭代 2:quality = 0.475,大小 = 0.8 MB ≤ 1 MB → lo = 0.475,記錄
迭代 3:quality = 0.5625,大小 = 1.1 MB > 1 MB → hi = 0.5625
迭代 4:quality = 0.519,大小 = 0.95 MB ≤ 1 MB → lo = 0.519,記錄
迭代 5:quality = 0.541,大小 = 1.02 MB > 1 MB → hi = 0.541
迭代 6:quality = 0.530,大小 = 0.98 MB ≤ 1 MB → lo = 0.530,記錄

最終選 quality ≈ 0.530,大小 0.98 MB < 1 MB ?

六、回退鏈(Fallback Chain)設(shè)計

工業(yè)級導(dǎo)出不只有"成功"和"失敗"兩種狀態(tài),而是一個優(yōu)先級降級鏈

// 優(yōu)先無損,失敗則降級有損,最終極限壓縮兜底
ImageExporter.saveWithFallback(bitmap, to: url, formats: [
    .png,                    // 優(yōu)先:無損(適合有透明度)
    .jpeg(quality: 0.85),   // 次選:高質(zhì)量 JPEG
    .jpeg(quality: 0.5),    // 再次:中等質(zhì)量
    .jpeg(quality: 0.3),    // 兜底:最低質(zhì)量
])
public static func saveWithFallback(
    _ bitmap: MLBitmap,
    to url: URL,
    formats: [ExportFormat]
) -> ExportResult {

    for format in formats {
        let targetURL = url.deletingPathExtension()
                           .appendingPathExtension(ext(for: format))
        let result = save(bitmap, to: targetURL, format: format)

        switch result {
        case .success:
            return result        // 第一個成功即返回
        case .failure(let err):
            logger.warning("格式[\(label(format))]失?。篭(err),嘗試下一個...")
        }
    }

    return .failure(.allFormatsFailed)
}

失敗的常見原因(不是格式本身的問題):

  • 磁盤空間不足(寫文件失?。?/li>
  • 內(nèi)存不足(大圖編碼時 OOM)
  • 權(quán)限問題(目標(biāo)路徑不可寫)

七、場景預(yù)設(shè)(Scenario Preset)

真實項目中,調(diào)用方不應(yīng)該每次都手動設(shè)置 policy,而是用預(yù)設(shè)場景

public enum ExportScenario {
    case socialShare              // 社交分享(微信/微博/Instagram)
    case ecommerce                // 電商主圖(淘寶/京東)
    case thumbnail(maxSize: Int)  // 縮略圖
    case archive                  // 存檔備份
    case custom(policy: ProcessingPolicy)  // 完全自定義
}

// 使用:
ImageProcessor.process(bitmap, to: url, scenario: .socialShare)

每個場景對應(yīng)一套 policy(最大尺寸、格式、質(zhì)量范圍、體積上限):

場景 長邊限制 格式 質(zhì)量范圍 體積上限
socialShare 1920 auto 0.75~0.88 3 MB
ecommerce 2048 JPEG 0.85~0.92
thumbnail 自定義 JPEG 0.7 0.6~0.75
archive 無限 PNG 0.92~0.95

八、尺寸重采樣

如果圖像超過最大長邊限制,需要等比縮放:

private static func resampleIfNeeded(_ bitmap: MLBitmap, policy: ProcessingPolicy) -> MLBitmap {
    guard let maxEdge = policy.maxLongEdge else { return bitmap }

    let longEdge = max(bitmap.width, bitmap.height)
    guard longEdge > maxEdge else { return bitmap }  // 未超限

    let scale = CGFloat(maxEdge) / CGFloat(longEdge)
    let newW  = max(1, Int(CGFloat(bitmap.width)  * scale))
    let newH  = max(1, Int(CGFloat(bitmap.height) * scale))

    return resample(bitmap, to: newW, by: newH)
}

private static func resample(_ bitmap: MLBitmap, to newW: Int, by newH: Int) -> MLBitmap {
    guard let uiImage = ImageExporter.toUIImage(bitmap) else { return bitmap }

    // ?? 關(guān)鍵:scale = 1.0 → 1 point = 1 pixel
    // 默認(rèn)跟隨屏幕 Retina 縮放(2x/3x),會導(dǎo)致輸出尺寸翻倍
    let format = UIGraphicsImageRendererFormat()
    format.scale = 1.0

    let renderer = UIGraphicsImageRenderer(size: CGSize(width: newW, height: newH), format: format)
    let resized  = renderer.image { _ in
        uiImage.draw(in: CGRect(x: 0, y: 0, width: newW, height: newH))
    }

    return (try? ImageLoader.load(from: resized)) ?? bitmap
}

九、日志系統(tǒng):os.log vs print

生產(chǎn)庫不應(yīng)該使用 print

比較 print os.log / Logger
性能 同步,阻塞 異步,幾乎無開銷
級別控制 debug/info/warning/error
過濾 無法過濾 Console.app 可過濾
符號記錄 不記錄 自動記錄調(diào)用棧
隱私 無保護(hù) 可標(biāo)記 sensitive 數(shù)據(jù)
// 生產(chǎn)代碼
private let logger = Logger(subsystem: "com.mlimage.core", category: "ImageProcessor")

logger.debug("重采樣:\(bitmap.width)×\(bitmap.height) → \(newW)×\(newH)")
logger.warning("文件體積超限,啟動降級重試")
logger.error("導(dǎo)出失?。篭(error.localizedDescription)")

十、ExportResult:結(jié)構(gòu)化結(jié)果

不要用 Bool 表示成功/失敗,結(jié)構(gòu)化結(jié)果提供更多信息:

public enum ExportResult {
    case success(format: ExportFormat, url: URL)
    case failure(ExportError)
}

public enum ExportError: Error {
    case encodingFailed(format: ExportFormat)    // 編碼失敗
    case writeFailed(url: URL, error: Error)     // 寫磁盤失敗
    case allFormatsFailed                         // 回退鏈全部失敗
}

調(diào)用方可以精確地判斷失敗原因:

switch result {
case .success(let format, let url):
    // 知道最終用了什么格式,保存在哪里
    print("成功:\(format) → \(url)")

case .failure(.writeFailed(let url, let error)):
    // 知道哪個路徑寫失敗了,可以提示用戶清理空間
    showAlert("存儲空間不足:\(error.localizedDescription)")

case .failure(.encodingFailed):
    // 極少見,通常是內(nèi)存不足
    showAlert("圖像編碼失敗,請重試")

case .failure(.allFormatsFailed):
    // 回退鏈全部失敗,情況很嚴(yán)重
    reportCrash()
}

十一、小結(jié)

知識點 核心內(nèi)容
PNG vs JPEG 透明度 → PNG;照片/體積優(yōu)先 → JPEG
JPEG 原理 DCT + 量化,quality 控制高頻細(xì)節(jié)保留程度
動態(tài)質(zhì)量 根據(jù)圖像復(fù)雜度(梯度均值)在質(zhì)量區(qū)間內(nèi)選擇
二分法 滿足體積約束的最優(yōu) quality 尋找
回退鏈 按優(yōu)先級依次嘗試,降級而非直接失敗
場景預(yù)設(shè) 調(diào)用方使用預(yù)設(shè),而非手動配置每個參數(shù)
等比縮放 scale = 1.0 避免 Retina 2x/3x 的 Points vs Pixels 陷阱
日志系統(tǒng) os.log 替代 print,支持級別控制和過濾

思考題

  1. JPEG 重新保存問題:如果對同一張 JPEG 圖片反復(fù)"打開 → 保存"(不做任何修改),每次保存后圖像質(zhì)量會變化嗎?為什么?
  2. 某電商平臺規(guī)定商品圖不超過 2 MB,但必須保證 2048×2048 尺寸。你會如何設(shè)計自動化的導(dǎo)出策略?
  3. WebP 格式是 Google 推出的現(xiàn)代圖像格式,相比 JPEG 能在相同質(zhì)量下減少約 25~34% 的文件大小,也支持透明度。iOS 14+ 支持顯示 WebP,但 UIKit 不支持直接導(dǎo)出。如果要在 Phase 2 添加 WebP 支持,需要引入哪些依賴?

答案:1. 會變差,因為每次 JPEG 保存都做一次有損壓縮,高頻信息每次都損失一點,多次后會出現(xiàn)明顯的塊狀偽影;2. 先縮放到 2048×2048,然后從 quality=0.92 開始二分搜索,找到滿足 2 MB 約束的最高質(zhì)量;3. 需要引入 libwebp(Google 的 C 庫),或者用 SDWebImage / Kingfisher 等支持 WebP 的第三方庫,也可以用 ImageIO 框架(iOS 14+ 支持讀取,但寫入仍需 libwebp)。

??喜歡我的內(nèi)容,歡迎大家點贊、轉(zhuǎn)發(fā)、關(guān)注。

??本人專注于技術(shù)+投資+認(rèn)知三位一體的內(nèi)容分享。

往期推薦:

為什么卷積核通常必須是奇數(shù)?

一圖了解卷積中的邊界處理

一圖了解幾種常用卷積核

一圖了解卷積的核心原理

一張圖帶你了解——卷積到底是什么?

一圖了解飽和度:控制色彩鮮艷程度的關(guān)鍵

一圖了解OCR的處理流程及相關(guān)圖像處理技術(shù)

一圖了解二值化與閾值,從灰度到黑白的決策

一張圖了解圖像處理中的亮度、對比度與實現(xiàn)

顏色科學(xué)與灰度化

從"圖片"到"內(nèi)存"——你真正理解圖像處理的第一天

iPhone相冊背后的圖像處理知識(下)

iPhone相冊背后的圖像處理知識(中)

iPhone相冊背后的圖像處理知識(上)

一張圖了解圖像處理的本質(zhì)

圖像到底是什么

圖像處理技術(shù)概要圖

AI時代,軟件工程師必備概念全景圖

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容