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

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

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

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

namespace Slashlook.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 Slashlook.Image
{
	/// <summary>
	/// 這個類別用來壓縮影像大小
	/// </summary>
	public class Compress : (請自己實作setException介面)
	{
		//建構子
		public Compress() { }

		//影像檔案來源路徑
		private string _cFileSourcePathAndName;
		//影像檔案目的路徑
		private string _cFileTargetPathAndName;
		//檔案壓縮程度
		private Slashlook.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 Slashlook.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;
		}

	}
}

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

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

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

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

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

		//轉換目標圖片的寬度
		private int? _iWidth;
		//轉換目標圖片的高度
		private int? _iHeight;
		//影像檔案來源路徑
		private string _cFileSourcePathAndName;
		//影像檔案目的路徑
		private string _cFileTargetPathAndName;
		//檔案壓縮程度
		private Slashlook.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 Slashlook.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;
		}

	}
}

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

Slashlook.Image.Resize oImg = new Slashlook.Image.Resize()
{
	cFileSourcePathAndName = @"\Source.jpg",
	cFileTargetPathAndName = @"\Target.jpg",
	eQuality = Slashlook.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