PDA

View Full Version : استخراج اثر امضا با استفاده از Ces.SignatureExtractor



mmbguide
شنبه 04 مرداد 1404, 23:14 عصر
در بسیاری از برنامه‌ها به منظور درج امضای کاربر در مستندات، تصویر اسکن شده‌ی امضا اعمال می‌شود که در بیشتر مواقع بدلیل وجود پس‌زمینه و یا کدر بودن (Opaque)، نتیجه مطلوبی ارائه نمی‌دهد. برای مثال در یک از پروژه‌ برای آنکه اثر امضا براحتی روی مستندات اعمال شود به گونه‌ای که خطوط امضا اجازه‌ی رسم شدن در نواحی مختلف را بدون پوشش محتوا بواسطه وجود پس‌زمینه داشته باشند منجر شد تا بااستفاده از Ces.SignatureExtractor از اثر امضا بجای تصویر اسکن شده‌ی امضا استفاده کنم.

استفاده از آن بسیار ساده‌ست. ابتدا یک نمونه از کلاس Extractor را ایجاد میکنیم و سپس متد Extract را صدا می‌زنیم و نتیجه یک مقدار Bitmap خواهد بود. نمونه زیر با استفاده از UI پیاده سازی شده:

var extractor = new Ces.SignatureExtractor.Extractor();

this.pbFinalImage.Image = extractor.Extract(
_openFileDialog.FileName
, chkUseOriginalColor.Checked
, btnCustomColor.BackColor
, (byte)tbBackgroundPrecision.Value);


پارامترهای متد:
imagePath: آدرس تصویر اسکن شده که قرار است توسط متد پردازش شود.
useOriginalColor: با فعال بودن این گزینه نتیجه با همان رنگ اصلی برگشت داده می‌شود.
customColor: در صورت نیاز می‌توان رنگ امضا را تغییر داد. قبل از آن باید گزینه useOriginalColor = false باشد.
precision: این مقدار بین 0 تا 255 می‌تواند تغییر کند و برای شناسایی و حذف رنگ پس‌زمینه می باشد. اگر پس‌زمینه خیلی کدر (Opaque) باشد باید این مقدار کاهش یابد.


نکته: رنگ امضا در تصویر اسکن شده نباید با خودکار قرمز باشد.


توضیحات کد:
ابتدا از آدرس ارسال شده یک تصویر با نام imgOriginal و سپس یک تصویر خالی با نام imgSignature و هم اندازه با imgSignature تعریف شده.
در ادامه دو حلقه تو در تو اجرا می‌شود که تمام پیکسل‌های تصویر اصلی رو بررسی می‌کند و در تصویر imgSignature و در همان موقعیت اعمال می‌کند. اما این مساله دو نکته دارد 1) بدست آوردن روشنایی رنگ 2) آیا این پیکسل در محدوده‌ی رنگ امضا می‌باشد یا خیر.
تعیین مرز بیرونی امضا (Boundry) که برنامه در انتها ناحیه خارج از مرز رو حذف و ناحیه امضا را بر‌ می‌گرداند.

محاسبه ضریب Alpha
مقدار brightness از متد GetBrightness بدست آمده و در ادامه مقدار alpha که همان شفافیت رنگ است محاسبه شده است. اما منطق این ضرایب چیست؟ یک رنگ بطور عادی دارای شدت 1 است و در دات‌نت برای آنکه بتوان شفافیت رنگ را تنظیم کرد باید مقدار Alphaی رنگ رو مشخص کنیم که عدد 0 کاملا شفاف است و رنگ دیده نمی‌شود و عدد 255 که حداکثر شدت رنگ است. بنابراین اگر در خط امضا نقاطی وجود داشته باشد (مانند انتهای رسم امضا) که دارای شدت کمتری هستند باید رنگ جدید (در صورت مشخص کردن customColor) با همان شدت رسم شود. در غیر اینصورت تمام خطوط با شدت 1 رسم می شود و زیبایی امضا از بین خواهد رفت.
اما alpha چطور محاسبه شده؟ اگر اثر امضا در نقطه‌ای حدود 20 درصد شفافیت کمتری داشته باشد در حقیقت 20 درصد از مقدار Alpha باید کم شود. بنابراین ضریب brightness که عددی float و بین 0 تا 1 است ابتدا از عدد 1 کم شده و سپس حاصل در 255 ضریب شده است تا میزان کاهش alpha بدست آید. حالا هر زمان که متد SetPixel رنگ را اعمکال می‌کند می‌تواند مقدار alpha را نیز در نظر بگیرد. با این روش اگر مسیر یک خط بدلیل کشیدن خودکار از آبی پررنگ به کم‌رنگ رسم شده باشد، برنامه ضمن تغییر رنگ، دقیقا همان شدت را در پیکسل جدید اعمال خواهد کرد.

خودکار قرمز ممنوع است
در ایجاد imgSignature برنامه باید بداند که آیا پیکسل جاری در محدوده خط امضا است یا خیر. تشخیص این موضوع برمبنای طیف رنگ‌هایی است که دارای رنگ قرمز از محدود 0 تا 200 می باشند. اگر رنگی با این ویژگی تشخیص داده شود، برنامه از آن عبور خواهد کرد و هیچ پیکسلی را به تصویر نهایی انتقال نخواهد داد. به همین دلیل اگر رنگ امضا با خودکار قرمز باشد برنامه نمی‌تواند امضا را رسم کند.


نکته: اگر در زمان پردازش تصویر و بدلیل تیرگی پس زمینه، اثر امضا به همراه بخشی از پس زمینه به عنوان نتیجه برگشت داده شد، باید مقدار پارامتر precision را کمتر از 200 در نظر بگیرید (به مثال زیر توجه کنید).


مرز بیرونی امضا
پس از نهایی شده imgSignature و پیدا شدن مرز بیرونی امضا، برنامه محدوده داخل مرز امضا را در یک تصویر جدید رسم کرده و به عنوان نتیجه برگشت خواهد داد.

مثال: در مثال زیر ناحیه اضافه حذف و رنگ امضا به آبی تغییر کرده است.
ایجاد تصویر با precision = 226
156565
ایجاد تصویر با precision = 185
156566

mmbguide
یک شنبه 05 مرداد 1404, 22:58 عصر
صرفا جهت نمونه ارسال شده
156567

mmbguide
چهارشنبه 08 مرداد 1404, 10:25 صبح
نسخه 2 در دسترس هست. دقیقتر و سریعتر

https://www.nuget.org/packages/Ces.SignatureExtractor

mmbguide
پنج شنبه 09 مرداد 1404, 11:47 صبح
مقدمه


کتابخانه Ces.SignatureExtractor (https://www.nuget.org/packages/Ces.SignatureExtractor) که بصورت عمومی در دسترس است قبلا معرفی شد.
مطلب جاری به کدهای Ces.SignatureExtractor ارتباط دارد ولی مشخصا بدلیل داشتن یک منبع کد، در این پست به آن اشاره شده است
متد 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);
}