مقدمه

  1. کتابخانه Ces.SignatureExtractor که بصورت عمومی در دسترس است قبلا معرفی شد.
  2. مطلب جاری به کدهای Ces.SignatureExtractor ارتباط دارد ولی مشخصا بدلیل داشتن یک منبع کد، در این پست به آن اشاره شده است
  3. متد Extract در این کتابخانه با کمک ChatGPT بهبود یافته.


نکته: خواهشمندم هر مطلب اصلاحی و تکمیلی در خصوص مطلب جاری مد نظر بود حتما ارسال بفرمایید.
معرفی BitmapData
این کلاس در فضای نام System.Drawing.Imaging قرار دارد که با استفاده از متدهای LockBits و UnlockBits استفاده می‌شود و این کلاس قابلیت ارث‌بری ندارد (Not inheritable). کلاس BitmapData خصیصه‌های (Attributes) یک تصویر Bitmap را مشخص می‌کند. ویژگی‌های (Properties) موجود در این کلاس شامل Width, Height, Stride, Scan0 و یک لیست شمارنده با نام PixelFormat می‌باشد:

  • Width: پهنای تصویر برحسب پیکسل که گاها تعداد پیکسل در هر اسکن از خط (Number of Pixels in One Scan Line) نیز گفته می‌شود.
  • Height: ارتفاع تصویر برحسب پیکسل که گاها تعداد خطوط اسکن (Number of Scan Lines) نیز گفته می‌شود.
  • Stride: پهنا/گام هر پیکسل. زمانی که متد LockBit تصویر را در حافظه قفل می‌کند و با توجه به پارامتر PixelFormat، فضایی که هر پیکسل باید در حافظه جهت نگهداری اطلاعات رنگ اشغال کند مشخص می‌شود. این مقدار برای هر پیکسل می‌تواند 24 یا 32 یا 64 و... بیت باشد. بنابراین در زمان خوانش خانه‌های حافظه باید بدانیم که چند بایت متعلق به یک پیکسل است که این موضوع تحت نام Stride یا پهنا/گام پیکسل شناخته می‌شود.
  • Scan0: آدرس اولین پیکسل (نشانگر حافظه (Pointer)) را بر می‌گرداند که در واقع می‌تواند مبنای خوانش تمام اطلاعات یک تصویر باشد.


توضیحات کد

  • متغیر imgOriginal: این متغیر از نوع Bitmap است و تصویر را از آدرس ارسال شده ایجاد خواهد کرد.
  • متغیر imgSignature: این متغیر نیز از نوع Bitmap است و با فرمت Format32bppArgb تعریف شده است. در این لیست شمارشی می‌توانید انواع فرمت‌ها را مشاهده کنید. در ادامه علت انتخاب Format32bppArgb توضیح داده شده.
  • متغیر rect: این متغیر یک مستطیل (Rectangle) به اندازه تصویر اصلی است که به عنوان پارامتر متد LockBit جهت تعیین محدوده‌ای از تصویر که باید در حافظه قفل شود تعریف شده است (ضروری نیست ولی برای تمیزی کد تعریف شده است).
  • متغیر bmpDataOriginal: این متغیر از نوع BitmapData است که متد LockBit بر‌ می‌گرداند. اولین پارامتر، ناحیه‌ای از تصویر اصلی است که باید در حافظه قفل شود. محدده‌ای که قفل می‌شود بدون تغییر خواهند ماند تا عملیات پایان یابد و در انتها با متد UnlockBits آزاد خواهد شد. پارامتر دوم نحوه قفل‌گذاری حافظه است که تصویر اصلی در حالت ReadOnly و تصویر نهایی WriteOnly می‌باشد. پارامتر سوم که PixelFormat است برای تصویر اصلی Format24bppRgb و برای تصویر خروجی Format32bppArgb می‌باشد.
  • متغیر bmpDataSignature: توضیحات مشابه bmpDataOriginal می‌باشد.


پارامتر PixelFormat
بدلیل آنکه تصویر اصلی یک تصویر اسکن شده است بنابراین هیچ نقطه‌ای دارای شفافیت (Transparency) نیست (چون زمینه کاغذ تماما سفید است) و طبق توضیحات، این نوع فرمت 24 بیت به ازای هر پیکسل نیاز دارد (Format24bppRgb) که هر 8 بیت برای یک رنگ مورد نیاز است و رنگ‌های آبی، قرمز و سبز مجموعا 24 بیت نیاز دارند (3 بایت).
اما تصویر خروجی بدلیل آنکه قرار است فقط اثر امضا را برگرداند بنابراین فضای سفید رنگ تصویر اسکن شده باید حذف شود که برای این کار باید آن نقاط دارای شفافیت کامل باشند و برای آنکه بتوان پارامتر Alpha در پیکسل را نگهداری کرد باید نوع Format32bppArgb که 32 بیت فضا نیاز دارد را انتخاب کرد که 8 بیت برای نگهداری مقدار Alpha مورد نیاز است که مجموعا 32 بیت اشغال خواهد کرد (4 بایت).

مقادیر Stride
اگر فرض کنیم که ابعاد تصویر اصلی دارای پهنای 200 پیکسل و ارتفاع 300 پیکسل باشد نتیجه مقادیر Stride برای تصویر اصلی 600 و برای تصویر خروجی 1200 خواهد بود و دلیل آن این است که هر پیکسل با توجه به PixelFormat دارای پهنای متفاوتی است و نگاشت 200 پیکسل در حافظه نیازمند 600 بیت فضا است (200*3) و برای تصویر خروجی نیز 1200 بیت (1200*4).

نکته: جهت کار با حافظه بصورت مستقیم باید دستورات بصورت unsafe اجرا شوند. استفاده از * نیز بدین منظور می‌باشد.
حلقه‌های تو در تو
منطق کار بدین صورت است که به تعداد ردیف‌های موجود (رجوع به توضیح Height) باید پهنای تصویر پردازش شود. داده‌های تصویر بصورت خطی (Linear) در حافظه قرار دارد و مانند روش استفاده مستقیم از Bitmap نیست که بصورت یک آرایه دو بعدی پیکسل‌ها خوانده می‌شود.

مقادیر Scan0
این مقادیر فقط یکبار محاسبه می‌شوند و نقش فاصله‌گذار (Padding Bytes) را دارند برای آنکه بدانیم در خوانش بعدی خانه‌های حافظه چه مقدار حرکت کنیم.

محاسبه پهنای تصاویر
این مقدار برابر است با مقدار Scan0 (همان Padding یا فاصله) ضربدر مقدار y (خط اسکن) ضربدر پهنای تصویر (Stride = پهنا * گام هر پیکسل). مقداری که در دو متغیر rowOriginal و rowSignature بدست می‌آید آدرس اولین خانه از حافظه در هر ردیف تصویر اصلی است که در حلقه بعدی تمام خانه‌های بعد از این نشانگر به عنوان اطلاعات رنگ ردیف جاری در تصویر اصلی خوانده خواهد شد.

byte* rowOriginal = ptrOriginal + (y * originalStride);
byte* rowSignature = ptrSignature + (y * signatureStride);


بدست آوردن پیکسل‌ها در هر ردیف

در هر مرحله از حلقه y نقطه شروع هر ردیف بدست آمده و در حلقه x باید به ازای پهنای تصویر (برحسب پیکسل) موقعیت هر پیکسل در حافظه را بخوانیم و با توجه به اینکه اطلاعات هر پیکسل در تصویر اصلی دارای سه مقدار 8 بیتی است بنابراین مشخصات RGB بصورت زیر بدست می‌آید (به ضریب 3 توجه کنید - تصویر اصلی 24 بیتی می‌باشد):
byte b = rowOriginal[x * 3 + 0];
byte g = rowOriginal[x * 3 + 1];
byte r = rowOriginal[x * 3 + 2];


محاسبه آلفا (شفافیت رنگ)

مقدار روشنایی اگر چه که با متد GetBrightness قابل دریافت است ولی محاسبه زیر دقیقتر می باشد.


float brightness = (0.299f * r + 0.587f * g + 0.114f * b) / 255f;
byte alpha = (byte)(255 * (1.0f - brightness));


نکته: درخصوص محاسبه روشنایی عبارت Standard Luminance Formula را در وب جستجو کنید.
تولید تصویر جدید
در ادامه با بررسی دامنه رنگ‌ها با مقداری که تحت نام precision به متد ارسال شده، می‌توان داده‌های رنگ جدید را (original یا custom) در حافظه بنویسیم (به ضریب 4 توجه کنید - تصویر نهایی 32 بیتی می‌باشد):

rowSignature[x * 4 + 0] = finalColor.B;
rowSignature[x * 4 + 1] = finalColor.G;
rowSignature[x * 4 + 2] = finalColor.R;
rowSignature[x * 4 + 3] = alpha; // Alpha


اگر اطلاعات رنگ جاری خارج از دامنه اعداد باشد مقدار 0 با شفافیت 0 را جایگزین میکنیم که عملا بخش خارج از خطوط امضا Transparent خواهد شد.

پایان پردازش
پس از اتمام عملیات پردازش تصویر، با استفاده از متد UnlockBits حافظه قفل شده را آزاد می‌کنیم (Unlock می‌شود ولی حافظه آزاد نمی‌شود مگر آنکه شیء Bitmap را Dispose کنیم که این مورد در انتهای عملیات و بدلیل استفاده از using در تعریف متغیر انجام خواهد شد).

متد GetNonTransparentBounds
این متد تصویر تولید شده را بررسی نهای خواهد کرد و با حذف فضای شفاف در اطراف ناحیه امضا، یک تصویر از ناحیه امضا برگشت خواهد داد. روند بررسی لبه‌های بالا، پایین، چپ و راست همانند متد Extract می باشد (با استفاده از BitmapData).

نتیجه استفاده از BitmapData
یک تصویر امضا که با استفاده از موبایل گرفته شده بود و اندازه آن 1016*732 و حجم 113 کیلوبایت بوده با روش اولیه 503 ms زمان صرف شد و با استفاده از BitmapData حدود 50 ms. یعنی افزایش ده برابری سرعت پردازش.
public Bitmap Extract(string imagePath, bool useOriginalColor = true, Color? customColor = null, byte precision = 200)
{
if (!useOriginalColor && !customColor.HasValue)
throw new ArgumentNullException(nameof(customColor));

using var imgOriginal = new Bitmap(Image.FromFile(imagePath));
var width = imgOriginal.Width;
var height = imgOriginal.Height;

// Final image for drawing extracted signature
var imgSignature = new Bitmap(width, height, PixelFormat.Format32bppArgb);

var rect = new Rectangle(0, 0, width, height);

// Lock both images for direct pixel access
var bmpDataOriginal = imgOriginal.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
var bmpDataSignature = imgSignature.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);

int originalStride = bmpDataOriginal.Stride;
int signatureStride = bmpDataSignature.Stride;

unsafe
{
byte* ptrOriginal = (byte*)bmpDataOriginal.Scan0;
byte* ptrSignature = (byte*)bmpDataSignature.Scan0;

for (int y = 0; y < height; y++)
{
byte* rowOriginal = ptrOriginal + (y * originalStride);
byte* rowSignature = ptrSignature + (y * signatureStride);

for (int x = 0; x < width; x++)
{
byte b = rowOriginal[x * 3 + 0];
byte g = rowOriginal[x * 3 + 1];
byte r = rowOriginal[x * 3 + 2];

float brightness = (0.299f * r + 0.587f * g + 0.114f * b) / 255f;
byte alpha = (byte)(255 * (1.0f - brightness));

// چشم پوشی از نقاط سفید و یا نواحی طوسی متمایل به سفید
// البته این مقدار که برنامه باید ازآن به عنوان نقاط اطراف
//خط امضا در نظر بگیرد به توسط پارامتر ورودی تابع تعیین می شود
if (r <= precision && g <= precision && b <= precision)
{
Color finalColor = useOriginalColor ? Color.FromArgb(r, g, b) : customColor.Value;

rowSignature[x * 4 + 0] = finalColor.B;
rowSignature[x * 4 + 1] = finalColor.G;
rowSignature[x * 4 + 2] = finalColor.R;
rowSignature[x * 4 + 3] = alpha; // Alpha
}
else
{
// Transparent
rowSignature[x * 4 + 0] = 0;
rowSignature[x * 4 + 1] = 0;
rowSignature[x * 4 + 2] = 0;
rowSignature[x * 4 + 3] = 0;
}
}
}
}

imgOriginal.UnlockBits(bmpDataOriginal);
imgSignature.UnlockBits(bmpDataSignature);

// برش و ناحیه امضا و حذف ناحیه شفاف خارج از محدوده امضا
var bounds = GetNonTransparentBounds(imgSignature);
if (bounds.Width <= 0 || bounds.Height <= 0)
return new Bitmap(1, 1); // Empty result if nothing found

var result = new Bitmap(bounds.Width, bounds.Height);
using (Graphics g = Graphics.FromImage(result))
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.DrawImage(imgSignature, new Rectangle(0, 0, bounds.Width, bounds.Height), bounds, GraphicsUnit.Pixel);
}

return result;
}

private Rectangle GetNonTransparentBounds(Bitmap bitmap)
{
int top = -1, bottom = -1, left = -1, right = -1;

BitmapData data = bitmap.LockBits(
new Rectangle(0, 0, bitmap.Width, bitmap.Height),
ImageLockMode.ReadOnly,
PixelFormat.Format32bppArgb);

int stride = data.Stride;
int width = bitmap.Width;
int height = bitmap.Height;

unsafe
{
byte* ptr = (byte*)data.Scan0;

// بدست آوردن مرز بالا و پایین ناحیه امضا
for (int y = 0; y < height; y++)
{
byte* row = ptr + (y * stride);
for (int x = 0; x < width; x++)
{
byte alpha = row[x * 4 + 3];
if (alpha != 0)
{
if (top == -1)
top = y;

bottom = y;
break;
}
}
}

// بدست آوردن مرز چپ و راست ناحیه امضا
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
byte* row = ptr + (y * stride);
byte alpha = row[x * 4 + 3];
if (alpha != 0)
{
if (left == -1)
left = x;

right = x;
break;
}
}
}
}

bitmap.UnlockBits(data);

if (top == -1 || left == -1)
return Rectangle.Empty;

return new Rectangle(left, top, right - left + 1, bottom - top + 1);
}