5.26. العثور على الخطوط والقطع

بعض ميزات المشهد ليست مناطق متصلة من اللون بل حواف مستقيمة ذات اتجاه: خط مرسوم على الأرض، أو الفاصل بين سطحين، أو جانب مستطيل مطبوع، أو حافة مدخل باب. وطلب كاشف الكتل أن يعثر عليها هو السؤال الخطأ -- فالحافة عرضها بكسل واحد، وخوارزمية الكتل تريد مساحة مع لون، فيعود الجواب فارغًا أو مليئًا بالضوضاء.

الكاشف الصحيح للحواف الموجّهة هو تحويل Hough للخطوط. تعرضه وحدة الصور بنكهتين: تُرجِع find_lines() خطوطًا لانهائية (يمتد كل خط عبر الصورة كاملة)؛ بينما تُرجِع find_line_segments() قطعًا محدودة (لكل خط نقطتا نهاية داخل الإطار). ويعتمد أيهما يحتاجه التطبيق على ما إذا كانت الحواف محل الاهتمام متصلة عبر الإطار كاملًا أم تمتد عبر جزء منه فقط.

5.26.1. كيف يعمل تحويل Hough

يتشارك كلا الكاشفين الفكرة الأساسية نفسها، لذا يُجدي فهمها مرة واحدة. تشغّل وحدة الصور أولًا مرشّح حواف على غرار Sobel على المدخلات لتسجّل كل بكسل حسب مدى احتمال وقوعه على حافة موجّهة. ثم يصوّت كل بكسل حافة من هذا النوع لجميع الخطوط التي قد يقع عليها. والخطوط التي تجمع أكثر الأصوات تفوز.

يُعطى الخط معاملات في فضاء Hough بعددين: theta، زاوية الخط (من 0 إلى 179 درجة)، وrho، المسافة العمودية من نقطة أصل الصورة إلى الخط (موقّعة، بالبكسل). كل خط تحتويه الصورة هو نقطة واحدة في فضاء (theta، rho). ويساهم كل بكسل حافة في المدخلات بصوت واحد لكل تركيبة (theta, rho) تتوافق مع موضعه -- وهو من الناحية المفاهيمية منحنى عبر فضاء Hough. وحيث تتقاطع منحنيات كثيرة كهذه، تتفق بكسلات حواف كثيرة على الخط نفسه، وذلك التقاطع هو عملية كشف.

يُرجِع الكاشف القيم العظمى المحلية في فضاء Hough التي تتجاوز مجاميع أصواتها عتبة. وتحمل كل Line مُرجَعة كلا التمثيلين: x1, y1, x2, y2 لصيغة نقاط النهاية (مقصوصة إلى حدود الصورة في الحالة اللانهائية)، وtheta, rho لصيغة Hough، وlength وmagnitude للحجم وعدد الأصوات على التوالي.

5.26.2. الخطوط اللانهائية

تشغّل find_lines() تحويل Hough وتُرجِع أقوى الخطوط، كل منها ممتدًا عبر الصورة كاملة:

lines = img.find_lines(threshold=1500, theta_margin=25, rho_margin=25)

for l in lines:
    img.draw_line(l, color=(255, 0, 0))

إن threshold هي الحد الأدنى لمجموع الأصوات لكي يُقبَل خط. يجمع مجموع الأصوات مقادير حواف Sobel لكل بكسل مساهم، لذا تتطلب قيم threshold الأكبر حوافَ أطول أو أقوى لتجتاز -- مما يجعل القيمة الصحيحة تعتمد على دقة الصورة (الخط الأطول عند دقة أعلى يراكم أصواتًا أكثر) إضافة إلى المشهد، فلا بد من ضبطها للتطبيق المعين. وكنقاط انطلاق تقريبية للضبط منها: 1000 لخط متواضع في صورة واضحة، و500 أو أقل للتباين الضعيف أو الخطوط القصيرة، و2000 أو أكثر للمشاهد المزدحمة حيث تتشكل خطوط إيجابية كاذبة عبر عناقيد من ضوضاء الحواف.

تتحكم theta_margin وrho_margin في دمج القيم العظمى المتقاربة. فالحافة الفيزيائية الواحدة تُنتج عنقودًا صغيرًا من السلال عالية التصويت حول (theta، rho) الحقيقيتين لها، ويطوي الكاشف كل عنقود إلى ذروته قبل الإرجاع. تدمج theta_margin=25 (بالدرجات) أي ذرى ضمن 25 درجة من الاتجاه؛ وتدمج rho_margin=25 (بالبكسل) الذرى ضمن 25 بكسلًا من المسافة. والقيم الافتراضية معقولة؛ ورفعها يُرجِع خطوطًا أقل وأكثر تمايزًا، وخفضها يُرجِع خطوطًا أكثر، مكررة أحيانًا.

تخطو x_stride وy_stride عبر بكسلات الحواف أثناء التصويت، بالطريقة نفسها التي تخطو بها عبر البكسلات في find_blobs(). والقيم الافتراضية 2 و1 تعمل للحالة الشائعة؛ ورفعها يسرّع البحث على حساب الدقة. وتقيّد roi البحث في منطقة من الإطار، مما يضيّق الخطوط المُرجَعة ويقلل العمل معًا.

كل خط مُرجَع قابل للرسم مباشرة: إذ يُمرَّر كائن Line مباشرة إلى draw_line()، التي تقرأ حقول نقاط النهاية (x1, y1, x2, y2) من مقدمته. وl.theta هي الزاوية بالدرجات، التي تصنّف الخط كأفقي أو رأسي أو قطري في مقارنة واحدة. وl.magnitude هي مجموع الأصوات، الذي يرتّب الخطوط المُرجَعة من الأقوى إلى الأضعف.

5.26.3. قطع الخطوط

إن find_lines() هي الكاشف الصحيح للحواف التي تمتد عبر الإطار كاملًا، لكن كثيرًا من الحواف الحقيقية -- الجانب الأيسر من باركود مطبوع، أو الحافة العليا لتسمية، أو الجانب المرئي من مسطرة -- تمتد عبر جزء من الصورة فقط. وتُرجِع find_line_segments() قطعًا محدودة تقع نقاط نهايتها داخل الإطار:

segments = img.find_line_segments(merge_distance=5, max_theta_difference=10)

for s in segments:
    img.draw_line(s, color=(0, 255, 0))

يتتبع كاشف القطع بكسلات الحواف الموجّهة مباشرة، بدلًا من التصويت في فضاء Hough، والنتيجة مجموعة من المسارات المستقيمة القصيرة. تحدد merge_distance أقصى فجوة بالبكسل يمكن لمسارين قصيرين على استقامة واحدة أن يمتدا عبرها ويظلا قابلين للدمج في قطعة مُرجَعة واحدة؛ وتحدد max_theta_difference كم درجة من الاتجاه يتسامح معها الدمج بين المسارات المتجاورة. الدمج السخي (merge_distance=10, max_theta_difference=15) يُرجِع عددًا صغيرًا من القطع الطويلة على حساب وصل حواف منفصلة فعلًا أحيانًا؛ والدمج الصارم (merge_distance=0, max_theta_difference=5) يُرجِع كثيرًا من القطع القصيرة ويترك للتطبيق فرزها في Python.

كائنات النتيجة هي من نوع Line نفسه الذي تُرجِعه find_lines()، بالخصائص نفسها، بحيث يستطيع خط أنابيب معالجة أي من نوعي الكشف عبر مسار الكود التالي نفسه. والفرق العملي الوحيد هو أن نقاط نهاية القطع هي النهايات الفعلية للخط في الصورة، بينما نقاط نهاية الخطوط اللانهائية هي حيثما يعبر الخط حدود الصورة.

5.26.4. متى تستخدم كلًا منهما

يتلخص الاختيار بين الطريقتين في سؤال واحد: هل يهتم التطبيق بأين يتوقف الخط؟

إن find_lines() هي الأداة الصحيحة عندما يكون الجواب لا. فالروبوت المتتبع للخط يحتاج إلى معرفة في أي اتجاه يسير الخط وأين يعبر أسفل الإطار؛ أما الخط نفسه فيمتد إلى الأفق وما بعده. وكاشف الأفق يريد أقوى حافة موجّهة في الصورة؛ ولا يحتاج إلى معرفة أين ينتهي الأفق.

إن find_line_segments() هي الأداة الصحيحة عندما يكون الجواب نعم. فتحديد الأضلاع الأربعة لمستطيل مطبوع يحتاج إلى أربع قطع بنقاط نهاية معروفة. وتتبع إصبع يشير إلى شاشة يعني متابعة قطعة قصيرة نقطتا نهايتها هما طرف الإصبع وقاعدته. وقياس طول خدش مرئي يحتاج إلى امتداد القطعة الفعلي بالبكسل.

يتشارك الكاشفان قيدًا مشتركًا: فهما يحتاجان إلى تباين. فمرشّح حواف Sobel الذي يُبنيان عليه يستجيب لتدرجات السطوع؛ والحافة الملونة على خلفية بالسطوع نفسه (خط أحمر على جدار أخضر بالنصوع نفسه) لا تُنتج تدرجًا ولا كشفًا. وعندما تظهر تلك الحالة عمليًا، يكون الحل هو استخراج قناة LAB واحدة كصورة بتدرج الرمادي ذات التباين الصحيح قبل البحث -- إذ تعزل to_grayscale() مع تحديد القناة b اللون الأحمر مقابل الأخضر حيث تكون قناة النصوع وحدها مسطحة -- ثم تسليم صورة تلك القناة إلى كاشف الخطوط.