使用System.Drawing(GDI+)進行圖片縮放、壓縮類別的設計

這幾天又開始再搞System.Drawing(GDI+)這方面的設計,發現無論在怎麼小心與釋放資源,總還是會有「記憶體不足」或「在GDI+中發生泛型錯誤」等訊息會在後端伺服器的記錄檔出現,因此下定決心在把網路上所有的文章再度爬過一遍,發現又有幾個新的論點出現,因此,將其彙整成一篇文章,供給有需要的人員參考。

這篇文章所有的工作輸出重點,都放在JPEG檔案格式,若您有別種檔案處理的需求,那只能參考一下程式碼自己實作了。

先建立一個JPEG圖像品質方面的列舉

namespace Slashview.Image
{
  /// /// 列舉:影像品質
  /// 
  [System.Serializable]
  public enum Quality
  {
    Highest = 75,
    High    = 70,
    Medium  = 65,
    Low     = 50,
    Lowest  = 25
  }
}

純壓縮影像類別

這裡要注意的重點有下列幾點:

  1. 所有有關於圖像檔案的取入、輸出,能夠盡量不要用到System.Drawing本身的檔案存取方法最好,因為這樣做有很高的機率會發生「記憶體不足」或「在GDI+中發生泛型錯誤」等錯誤,建議一律掛上MomoryStream或FileStream來動作會最佳。
  2. 在伺服器後端運作時,同一組DLL似乎會受到CLR進行某一種程度的記憶體管控(儘管你的伺服器記憶體還剩很多,耗用過多的記憶體仍會被GDI+判定為記憶體不足),這方面最好使用lock來保證執行緒的唯一,如此一來會讓錯誤率更降低。
namespace Slashview.Image
{
  /// <summary>
  /// 這個類別用來壓縮影像大小
  /// </summary>
  public class Compress : (請自己實作setException介面)
  {
    //建構子
    public Compress() { }

    //影像檔案來源路徑
    private string _cFileSourcePathAndName;
    //影像檔案目的路徑
    private string _cFileTargetPathAndName;
    //檔案壓縮程度
    private Slashview.Image.Quality? _eQuality;
    //實作資源鎖
    private static System.Object _oLock = new System.Object();

    /// <summary>
    /// 設定來源檔案路徑與檔名
    /// </summary>
    public string cFileSourcePathAndName
    {
      set
      {
        System.IO.FileInfo oFI = new System.IO.FileInfo(value);
        if (!oFI.Exists) { setException("來源檔案並不存在所指定的路徑中。"); }
        else
        { _cFileSourcePathAndName = value; }
      }
      get { return _cFileSourcePathAndName; }
    }
    
    /// <summary>
    /// 設定目的檔案路徑與檔名
    /// </summary>
    public string cFileTargetPathAndName
    {
      set { _cFileTargetPathAndName = value; }
      get { return _cFileTargetPathAndName; }
    }

    /// <summary>
    /// 設定影像壓縮品質
    /// </summary>
    public Slashview.Image.Quality? eQuality
    {
      set { _eQuality = value; }
      get { return _eQuality; }
    }

    /// <summary>
    /// 執行圖片壓縮
    /// </summary>
    public void Run()
    {
      //因為處理影像需要耗用系統很大的資源,因此限制同一時間只能有一個執行緒來進行此類別的調用
      lock (_oLock)
      { //檢查必要資訊
        CheckEssential();
        //執行壓縮圖片方法
        CompressImage();
      }
    }

    /// <summary>
    /// 將圖片壓縮至指定的品質(執行後結果將會把壓縮後的檔案放在指定的目錄中)
    /// </summary>
    private void CompressImage()
    {
      //開始進行影像處理(GDI+是一個很脆弱的物件,要處處呵護)
      try
      {
        using (System.IO.FileStream oFSSource = new System.IO.FileStream(_cFileSourcePathAndName, System.IO.FileMode.Open, System.IO.FileAccess.Read))
        { //採用Image.FromStream而非Image.FromFile,是因為據說「比較不會」引發記憶體不足
          using (System.Drawing.Image oImage = System.Drawing.Image.FromStream(oFSSource, false, false))
          {
            //設定編碼器
            System.Drawing.Imaging.ImageCodecInfo oJpegCodec = GetEncoder(System.Drawing.Imaging.ImageFormat.Jpeg);
            //設定編碼參數包
            System.Drawing.Imaging.EncoderParameters oEPs = new System.Drawing.Imaging.EncoderParameters(3);
            oEPs.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.Quality,      (int)_eQuality);
            oEPs.Param[1] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.ScanMethod,   (int)System.Drawing.Imaging.EncoderValue.ScanMethodInterlaced);
            oEPs.Param[2] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.RenderMethod, (int)System.Drawing.Imaging.EncoderValue.RenderProgressive);
            //把影像物件導向到檔案串流,再回寫至磁碟(oImage.Save方法很脆弱,只要磁碟稍忙碌或是權限出現一些問題,就會跳出「在GDI+中發生泛型錯誤」)
            using (System.IO.FileStream oFSTarget = System.IO.File.Open(_cFileTargetPathAndName, System.IO.FileMode.Create))
            {
              oImage.Save(oFSTarget, oJpegCodec, oEPs);
              oFSTarget.Flush();
            }
          }
        }
      }
      catch (System.Exception oEx)
      {
        setException(string.Format(
          "於 {0} 發生錯誤,錯誤原因為:{1}",
          System.DateTime.Now.ToString("yyyyMMdd HH:mm:ss"),
          oEx.Message
        ));
      }
    }

    /// <summary>
    /// 檢查必要資訊
    /// </summary>
    private void CheckEssential()
    {
      //檢查檔案來源路徑是否無設定
      if (string.IsNullOrEmpty(_cFileSourcePathAndName)) { setException("來源路徑不可為空值。"); }
      //檢查檔案目標路徑是否無設定
      if (string.IsNullOrEmpty(_cFileTargetPathAndName)) { setException("目標路徑不可為空值。"); }
      //檢查來源與目標路徑是否相同(不可以為相同,因為會產生咬檔的問題)
      if (_cFileSourcePathAndName.Equals(_cFileTargetPathAndName)) { setException("來源與目標路徑不可為相同值。"); }
      //檢查影像壓縮品質是否無設定
      if (_eQuality == null) { setException("目標路徑不可為空值。"); }
    }

    /// <summary>
    /// 取得影像處理編碼器(codec)
    /// </summary>
    /// <param name="oFormat">影像格式</param>
    /// <returns>編碼器</returns>
    private System.Drawing.Imaging.ImageCodecInfo GetEncoder(System.Drawing.Imaging.ImageFormat oFormat)
    {
      System.Drawing.Imaging.ImageCodecInfo[] aryCodecs = System.Drawing.Imaging.ImageCodecInfo.GetImageDecoders();
      foreach (System.Drawing.Imaging.ImageCodecInfo oCodec in aryCodecs)
      { if (oCodec.FormatID == oFormat.Guid) { return oCodec; } }
      return null;
    }

  }
}

調用方法很簡單,請參考下列的程式碼應可以意會:

Slashview.Image.Compress oImg = new Slashview.Image.Compress()
{
  cFileSourcePathAndName = @"\Source.jpg",
  cFileTargetPathAndName = @"\Target.jpg",
  eQuality = Slashview.Image.Quality.Highest
};
oImg.Run();
//最後建議將整個物件都釋放掉
oImage = null;

縮放影像後再進行壓縮影像類別

這裡要注意的重點有下列幾點:

  1. 上面所有關於影像壓縮時的重點,在這邊的作法上一併繼承。
  2. System.Drawing.Graphics在進行作圖優化時,要小心插補法模式「InterpolationMode」會耗用到更多的記憶體。在敝人處理圖檔的經驗中,發現在寬度超過15000像素以上的圖檔,進行插點運算時也會發生System.OutOfMemoryException,因此在這邊特別設計一個記憶體緩衝溢位的看門狗,試圖讓運算可以有更進一步的機會可以不要出錯。(事實證明,有效!)
namespace Slashview.Image
{
  /// <summary>
  /// 這個類別用來變更影像大小
  /// </summary>
  public class Resize : Slashview.Base.Base
  {
    //建構子
    public Resize() { }

    //轉換目標圖片的寬度
    private int? _iWidth;
    //轉換目標圖片的高度
    private int? _iHeight;
    //影像檔案來源路徑
    private string _cFileSourcePathAndName;
    //影像檔案目的路徑
    private string _cFileTargetPathAndName;
    //檔案壓縮程度
    private Slashview.Image.Quality? _eQuality;
    //實作資源鎖
    private static System.Object _oLock = new System.Object();
    //記憶體看門狗
    private bool _bIsOutOfMemory = false;

    /// <summary>
    /// 設定轉換目標圖片的寬度
    /// </summary>
    public int? iWidth
    {
      set { _iWidth = value; }
      get { return _iWidth; }
    }

    /// <summary>
    /// 設定轉換目標圖片的高度
    /// </summary>
    public int? iHeight
    {
      set { _iHeight = value; }
      get { return _iHeight; }
    }

    /// <summary>
    /// 設定來源檔案路徑與檔名
    /// </summary>
    public string cFileSourcePathAndName
    {
      set
      {
        System.IO.FileInfo oFI = new System.IO.FileInfo(value);
        if (!oFI.Exists) { setException("來源檔案並不存在所指定的路徑中。"); }
        else
        { _cFileSourcePathAndName = value; }
      }
      get { return _cFileSourcePathAndName; }
    }

    /// <summary>
    /// 設定目的檔案路徑與檔名
    /// </summary>
    public string cFileTargetPathAndName
    {
      set { _cFileTargetPathAndName = value; }
      get { return _cFileTargetPathAndName; }
    }

    /// <summary>
    /// 設定影像壓縮品質
    /// </summary>
    public Slashview.Image.Quality? eQuality
    {
      set { _eQuality = value; }
      get { return _eQuality; }
    }

    /// <summary>
    /// 執行圖片壓縮
    /// </summary>
    public void Run()
    {
      //因為處理影像需要耗用系統很大的資源,因此限制同一時間只能有一個執行緒來進行此類別的調用
      lock (_oLock)
      { //檢查必要資訊
        CheckEssential();
        //執行縮放與壓縮圖片方法
        ResizeAndCompressImage();
      }
    }

    /// <summary>
    /// 將圖片縮放後,並壓縮至指定的品質(執行後結果將會把壓縮後的檔案放在指定的目錄中)
    /// </summary>
    private void ResizeAndCompressImage()
    {
      //開始進行影像處理(GDI+是一個很脆弱的物件,要處處呵護)
      try
      {
        using (System.IO.FileStream oFSSource = new System.IO.FileStream(_cFileSourcePathAndName, System.IO.FileMode.Open, System.IO.FileAccess.Read))
        { //採用Image.FromStream而非Image.FromFile,是因為據說「比較不會」引發記憶體不足
          using (System.Drawing.Image oImageSource = System.Drawing.Image.FromStream(oFSSource, false, false))
          {
            using (System.Drawing.Bitmap oImageTarget = new System.Drawing.Bitmap((int)_iWidth, (int)_iHeight))
            {
              /* 將原始圖檔繪製到目的圖檔 */
              using (System.Drawing.Graphics oGraph = System.Drawing.Graphics.FromImage(oImageTarget))
              {
                //最佳化繪圖輸出
                oGraph.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
                oGraph.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
                oGraph.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
                //如果發生記憶體不足的情況,就關閉下列最佳化效果
                if (!_bIsOutOfMemory)
                {
                  oGraph.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
                }
                //清除畫布
                oGraph.Clear(System.Drawing.Color.Transparent);
                //進行縮圖繪製
                oGraph.DrawImage(oImageSource, 0, 0, (int)_iWidth, (int)_iHeight);
              }

              /* 進行編碼與儲存 */
              //設定編碼器
              System.Drawing.Imaging.ImageCodecInfo oJpegCodec = GetEncoder(System.Drawing.Imaging.ImageFormat.Jpeg);
              //設定編碼參數包
              System.Drawing.Imaging.EncoderParameters oEPs = new System.Drawing.Imaging.EncoderParameters(3);
              oEPs.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.Quality,      (int)_eQuality);
              oEPs.Param[1] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.ScanMethod,   (int)System.Drawing.Imaging.EncoderValue.ScanMethodInterlaced);
              oEPs.Param[2] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.RenderMethod, (int)System.Drawing.Imaging.EncoderValue.RenderProgressive);
              //把影像物件導向到檔案串流,再回寫至磁碟(oImage.Save方法很脆弱,只要磁碟稍忙碌或是權限出現一些問題,就會跳出「在GDI+中發生泛型錯誤」)
              using (System.IO.FileStream oFSTarget = System.IO.File.Open(_cFileTargetPathAndName, System.IO.FileMode.Create))
              {
                oImageTarget.Save(oFSTarget, oJpegCodec, oEPs);
                oFSTarget.Flush();
              }
            }
          }
        }
        //如果有正確執行完成,就把記憶體看門狗開關打開
        _bIsOutOfMemory = false;
      }
      catch  (System.OutOfMemoryException oEx)
      {
        if (!_bIsOutOfMemory)
        { //關閉優化圖片特效,再給一次機會
          _bIsOutOfMemory = true;
          ResizeAndCompressImage();
        }
        else
        {
          setException(string.Format(
            "於 {0} 發生錯誤且繪圖優化效果已經關閉,錯誤原因為:{1}",
            System.DateTime.Now.ToString("yyyyMMdd HH:mm:ss"),
            oEx.Message
          ));
        }
      }
      catch (System.Exception oEx)
      {
        setException(string.Format(
          "於 {0} 發生錯誤,錯誤原因為:{1}",
          System.DateTime.Now.ToString("yyyyMMdd HH:mm:ss"),
          oEx.Message
        ));
      }
    }

    /// <summary>
    /// 檢查必要資訊
    /// </summary>
    private void CheckEssential()
    {
      //檢查目標圖片寬度是否無設定
      if (_iWidth == null || _iWidth <=0)    { setException("目標圖片寬度不可以無值或是小於等於零。"); }
      //檢查目標圖片高度是否無設定
      if (_iHeight == null || _iHeight <= 0) { setException("目標圖片高度不可以無值或是小於等於零。"); }
      //檢查檔案來源路徑是否無設定
      if (string.IsNullOrEmpty(_cFileSourcePathAndName)) { setException("來源路徑不可為空值。"); }
      //檢查檔案目標路徑是否無設定
      if (string.IsNullOrEmpty(_cFileTargetPathAndName)) { setException("目標路徑不可為空值。"); }
      //檢查來源與目標路徑是否相同(不可以為相同,因為會產生咬檔的問題)
      if (_cFileSourcePathAndName.Equals(_cFileTargetPathAndName)) { setException("來源與目標路徑不可為相同值。"); }
      //檢查影像壓縮品質是否無設定
      if (_eQuality == null) { setException("目標路徑不可為空值。"); }
    }

    /// <summary>
    /// 取得影像處理編碼器(codec)
    /// </summary>
    /// <param name="oFormat">影像格式</param>
    /// <returns>編碼器</returns>
    private System.Drawing.Imaging.ImageCodecInfo GetEncoder(System.Drawing.Imaging.ImageFormat oFormat)
    {
      System.Drawing.Imaging.ImageCodecInfo[] aryCodecs = System.Drawing.Imaging.ImageCodecInfo.GetImageDecoders();
      foreach (System.Drawing.Imaging.ImageCodecInfo oCodec in aryCodecs)
      { if (oCodec.FormatID == oFormat.Guid) { return oCodec; } }
      return null;
    }

  }
}

調用方法很簡單,請參考下列的程式碼應可以意會:

Slashview.Image.Resize oImg = new Slashview.Image.Resize()
{
  cFileSourcePathAndName = @"\Source.jpg",
  cFileTargetPathAndName = @"\Target.jpg",
  eQuality = Slashview.Image.Quality.Highest,
  iWidth  = 999,
  iHeight = 999
};
oImg.Run();
//最後建議將整個物件都釋放掉
oImage = null;

祝大家在使用System.Drawing(GDI+)不要再出現「記憶體不足」或「在GDI+中發生泛型錯誤」的錯誤訊息了(雖然說不太可能...)。如果還有哪些地方在設計上可以修改得更好之處,也歡迎大家留言分享。

後記:使用WPF提供的JpegBitmapEncoder來進行壓縮

WPF(Windows Presentation Foundation)的JpegBitmapEncoder其實在後端也是依存WIC(Windows Imaging Component)元件來進行存取,如果對於GDI+垃圾般的壓縮品質感到失望的話,或許可以藉由這個類別來進行某種程度的優化。(不過,還是很垃圾,不用抱太大期望)

若要進行WPF提供的JpegBitmapEncoder來進行壓縮,首先你必須要先下兩個參考:

<add assembly="PresentationCore, Version=4.0.0.0,  Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
<add assembly="WindowsBase,      Version=4.0.0.0,  Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>

使用方式其實比GDI+更簡單,品質雖然比較好一點,但是檔案瞬間臃腫的很厲害(過不了Google PageSpeed Insights的Optimize Images檢測)。因為不是此篇文章的重點,因此我並不想做太多解釋:

using (System.IO.FileStream oFSSource = new System.IO.FileStream("original.jpg", System.IO.FileMode.Open, System.IO.FileAccess.Read))
using (System.IO.FileStream oFSTarget = new System.IO.FileStream("target.jpg", System.IO.FileMode.Create))
{
  System.Windows.Media.Imaging.BitmapFrame oBF = System.Windows.Media.Imaging.BitmapFrame.Create(oFSSource);
  System.Windows.Media.Imaging.JpegBitmapEncoder oEncoder = new System.Windows.Media.Imaging.JpegBitmapEncoder()
  { QualityLevel = 100 };
  oEncoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(oBF));
  oEncoder.Save(oFSTarget);
}
System.Drawing GDI+ OutOfMemoryException MemoryLeak WPF JpegBitmapEncoder WIC WindowsImagingComponent