الأدلة التقنية

بوابة تفتح تلقائياً بقراءة لوحة السيارة: مشروع عملي

كيف تبني بوابة لمجمع سكني أو شركة تقرأ لوحة السيارة وتفتح للمصرح لهم، مع شاشة للحارس وسجل لكل دخول.

بوابة تفتح تلقائياً بقراءة لوحة السيارة: مشروع عملي

في كل مجمع سكني أو شركة لها بوابة سيارات، يتكرر المشهد نفسه عشرات المرات في اليوم: سيارة تقف أمام الذراع، والحارس يرفع رأسه من الشاشة، ويتعرف على السيارة أو يسأل صاحبها، ثم يضغط الزر. والفكرة التي تخطر على البال هي أن تقرأ كاميرا لوحة السيارة وتفتح البوابة وحدها لمن يحق له الدخول، وهذا ما يسمى قراءة اللوحات آلياً Automatic Number Plate Recognition، واختصاراً ANPR، ويسميه البعض ALPR. وفي مقال أنواع كاميرات المراقبة اخترنا الكاميرا، وفي مقال Frigate سجلنا بها وكشفنا السيارات، وفي تجربة قراءة رقم المبنى رأينا أن الاكتشاف Detection يجب أن يسبق القراءة OCR. وهذا المقال من مرحلة معالجة الصور والمستندات في خريطة الطريق يجمع ذلك كله في مشروع واحد كامل، وسوف نناقش فيه ما يلي:

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

ماذا يحتاج الحارس من النظام؟

قد يتساءل البعض: لماذا نبدأ من الحارس وليس من النموذج الذي يقرأ اللوحة؟ والإجابة أن قراءة اللوحة هي الجزء السهل نسبياً، أما ما يجعل النظام يعمل كل يوم فهو ما يحدث حول القراءة، وإذا جلست مع حارس بوابة نصف ساعة سوف تخرج بقائمة تشبه القائمة التالية:

  • قائمة السيارات المصرح لها Allowlist: لوحة كل ساكن أو موظف، ومعها اسمه ورقم الوحدة أو القسم، ومن أضافها ومتى، لأن الحارس يحتاج أن يعرف لمن السيارة وليس رقمها فقط.
  • تصريح الزائر المؤقت Visitor Pass: السباك أو الضيف أو سيارة التوصيل يدخل اليوم فقط، أو لساعتين، ثم ينتهي تصريحه وحده دون أن يتذكر أحد حذفه، والسبب أن القوائم التي يضاف إليها ولا يحذف منها تكبر حتى يصبح نصفها سيارات لا يعرفها أحد.
  • الفتح اليدوي مع سبب Manual Override: الكاميرا ستخطئ، والكهرباء ستنقطع، وسيارة الإسعاف لن تنتظر، لذلك يجب أن يستطيع الحارس الفتح بزر، ولكن مع سبب مكتوب واسمه، حتى لا يصبح الزر طريقة دائمة لتجاوز النظام.
  • قرار الحارس عند الشك: عندما يقرأ النظام لوحة قريبة من لوحة مسجلة، أو يقرأ بثقة منخفضة، فالأفضل أن يعرض الحالة على الحارس بدل أن يفتح أو يرفض وحده.
  • سجل الأحداث Event Log: كل قراءة وكل قرار وكل فتح يدوي في سطر واحد فيه الوقت والكاميرا واللوحة والقرار والسبب ومن فعله، وهذا السجل هو ما ترجع إليه عندما يسألك أحد: من دخل ليلة الخميس؟
  • العمل دون الإنترنت: البوابة يجب أن تفتح لسكانها إذا انقطع الإنترنت، لذلك يعمل كل شيء على سيرفر داخل المكان.

ولاحظ أن نصف هذه المتطلبات لا علاقة له بالذكاء الاصطناعي، وإنما هي إدارة صلاحيات عادية، وهي نفس فكرة دورة حياة حساب الموظف JML: الساكن الذي يغادر المجمع يجب أن تحذف سيارته في اليوم نفسه، كما يغلق حساب الموظف الذي يغادر الشركة.

المعمارية الكاملة: مكونات البوابة وكيف تتصل ببعضها

الصورة التالية تبين المكونات الستة للمشروع، والأرقام فيها تقابل القائمة تحتها:

مخطط من ستة أجزاء: كاميرا موجهة إلى سيارة ترسل بث RTSP إلى سيرفر البوابة، وفي السيرفر ثلاث خطوات Detect ثم OCR ثم Rules، ثم سهم HTTP إلى Relay يرسل نبضة قصيرة إلى وحدة تحكم البوابة وذراعها، وتحت السيرفر قاعدة SQLite فيها القائمة والتصاريح والسجل، وشاشة الحارس متصلة بالسيرفر
مكونات البوابة التي تقرأ اللوحة، من الكاميرا إلى ذراع البوابة
  1. الكاميرا: كاميرا شبكية IP Camera موجهة إلى مكان وقوف السيارة أمام الذراع، ترسل بثها عبر RTSP كما شرحنا في قسم RTSP وONVIF.
  2. سيرفر البوابة: يأخذ الإطار من البث، ويجد مكان اللوحة بنموذج اكتشاف Detector، ويقرأ حروفها بنموذج OCR، ثم يطبق القواعد: هل اللوحة في القائمة؟ هل التصريح ساري؟ هل الثقة كافية؟
  3. ال Relay: جهاز صغير على الشبكة فيه مفتاح كهربائي، يطلب منه السيرفر عبر HTTP أن يغلق تلامسه لحظة قصيرة ثم يفتحه.
  4. وحدة التحكم في البوابة Gate Controller: اللوحة الإلكترونية التي تأتي مع البوابة نفسها، وفيها مدخل للزر أو جهاز التحكم عن بعد، وتلامس ال Relay يبدو لها كأن أحداً ضغط الزر.
  5. قاعدة البيانات: قائمة السيارات وتصاريح الزوار وجدول الأحداث، وفي التجربة نستخدم SQLite لأنها ملف واحد يكفي بوابة أو بوابتين.
  6. صفحة الحارس: صفحة ويب على شاشة في غرفة الحراسة تعرض آخر الأحداث والسيارات التي تنتظر قراره، وفيها زر الفتح اليدوي.

وقد يتساءل البعض: لماذا لا ترسل الكاميرا الصور إلى خدمة سحابية تقرأ اللوحات؟ والإجابة أن البوابة يجب أن تفتح إذا انقطع الإنترنت، وأن صور السيارات ولوحاتها بيانات عن الناس وتحركاتهم كما سيأتي في قسم الأنظمة، فالأنسب أن تبقى داخل المكان. ولاحظ أيضاً أن الكاميرا وال Relay ووحدة التحكم أجهزة يسهل اختراقها إذا كانت على نفس شبكة الضيوف، لذلك ضعها في شبكة منفصلة لا يصل إليها إلا سيرفر البوابة، كما شرحنا في مقال تقسيم الشبكة Network Segmentation.

البوابة كاملة في الموقع

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

ست مناطق. CAMERA NETWORK فيها كاميرتا اللوحات gate-in وgate-out ترسلان الإطارات عبر POST /api/camera/gate-in إلى Gate server في GATE SERVER NETWORK، وهو FastAPI مع fast-alpr وONNX Runtime يكتشف ويقرأ ويقرر، ويكتب في Database من نوع SQLite ثم PostgreSQL جدولي vehicles وevents، وبجانبه Frigate اختياري للتسجيل وقراءته الخاصة للوحات عبر MQTT وMosquitto يرسل plate event. والسيرفر يرسل POST /pulse مع توكن إلى Relay gate-in وRelay gate-out في GATE DEVICES NETWORK، وكل Relay يغلق تلامساً جافاً في وحدة التحكم والذراع. وفي GUARD ROOM OFFICE صفحة الحارس وصفحة الإدارة للسكان وتصاريح الزوار تمران عبر Caddy مع authentik بتسجيل دخول HTTPS إلى السيرفر. وفي BACKUP نسخة restic مشفرة خارج الموقع كل ليلة. وفي INTERNET: لا يحتاجه فتح البوابة، فقط للتحديثات والنسخ والتنبيهات، ولا يوجد طريق من شبكات الأجهزة إليه
الكاميرات وال Relays في شبكات لا يصل إليها إلا سيرفر البوابة، والبوابة تفتح دون الإنترنت

المكونات والأدوات المقترحة

المكونمسؤوليتهأداة مفتوحة المصدر مقترحةلماذا هي
اكتشاف اللوحة وقراءتهاصندوق اللوحة ثم حروف الصف اللاتينيfast-alpr (MIT) مع ONNX Runtime (MIT)يعمل على المعالج دون كرت شاشة، وهو ما قسنا دقته في التجربة
خدمة البوابةالقرار والقائمة والتصاريح والسجل وصفحة الحارسFastAPI (MIT) مع uvicorn، كما في التجربةخدمة صغيرة واحدة تكفي عدة بوابات، والقواعد كلها في دالة decide
قاعدة البياناتالسيارات والتصاريح وسجل الأحداثSQLite (ملكية عامة Public Domain)، ثم PostgreSQL (PostgreSQL License) عند عدة مواقعملف واحد يكفي بوابة أو بوابتين، وPostgreSQL عندما تحتاج عدة كتاب في نفس الوقت أو مزامنة بين مواقع
ال Relayنبضة قصيرة على مدخل الزر في وحدة التحكملوحة ESP32 مع ESPHome (MIT للجزء المكتوب بلغة Python وGPLv3 لكود الجهاز)، أو جهاز Shelly التجاريESPHome فيه مثال الزر نفسه، ويعمل محلياً دون سحابة
التسجيل والقراءة الاحتياطيةتسجيل الكاميرات، وقراءة اللوحات من نفس البث إذا أردتFrigate (MIT) مع Mosquitto (EPL-2.0 أو EDL-1.0)إذا كان Frigate يعمل عندك أصلاً، فخدمة البوابة تستطيع أن تأخذ القراءة منه عبر MQTT بدل أن تقرأ بنفسها
الدخول إلى صفحة الحارسTLS وتسجيل دخول كل حارس باسمهCaddy (Apache-2.0) مع authentik (MIT للنسخة المفتوحة)اسم الحارس في السجل يأتي من تسجيل الدخول بدل رمز مشترك يعرفه الجميع
النسخ الاحتياطينسخة مشفرة من قاعدة البيانات والإعداد خارج الموقعrestic (BSD-2-Clause)نسخ مشفرة ومتزايدة، وقد قارناه بغيره في restic أم Borg أم Kopia
التنبيهرسالة عندما لا ترد كاميرا أو Relay أو عندما يتكرر relay_errorUptime Kuma (MIT)يفحص كل جهاز على الشبكة كل دقيقة، ويرسل التنبيه قبل أن يقف ساكن أمام ذراع لا يرتفع

نموذج البيانات

الجدولان vehicles وevents هما جدولا التجربة نفسها، والجدولان الآخران ما يضاف عندما يصبح عندك أكثر من بوابة وأكثر من حارس:

-- from the lab (app.py)
CREATE TABLE vehicles (
  plate TEXT PRIMARY KEY,                 -- one key: digits, then Latin letters, e.g. 8964XGA
  owner TEXT NOT NULL, unit TEXT NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('resident', 'visitor')),
  valid_until TEXT,                       -- visitors only: the pass ends by itself
  added_by TEXT NOT NULL, added_at TEXT NOT NULL);
CREATE TABLE events (                     -- append only: the code never updates or deletes a row
  id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, camera TEXT NOT NULL,
  plate_read TEXT, confidence REAL, plate_matched TEXT,
  decision TEXT NOT NULL,                 -- open, deny, ask_guard, ignore, override, relay_error, list_add
  reason TEXT NOT NULL, actor TEXT NOT NULL);

-- added for several gates: which camera opens which relay
CREATE TABLE gates (
  camera TEXT PRIMARY KEY,                -- 'gate-in', 'gate-out' (the {camera} in /api/camera/{camera})
  relay_url TEXT NOT NULL,
  min_conf REAL NOT NULL DEFAULT 0.90,    -- lab: MIN_CONF
  repeat_seconds INTEGER NOT NULL DEFAULT 15);

-- added when residents leave: removal is an event too (see JML)
CREATE TABLE vehicle_changes (
  plate TEXT NOT NULL, change TEXT NOT NULL CHECK (change IN ('added', 'removed', 'extended')),
  by TEXT NOT NULL, at TEXT NOT NULL, note TEXT);

الواجهات الرئيسية

الواجهات الخمس الأولى هي واجهات خدمة التجربة كما هي، والسادسة ما ينقصها في بوابة حقيقية:

الطريقةالمسارماذا يفعل
POST/api/camera/{camera}صورة من الكاميرا، فيرد بالقراءة والقرار والسبب، ويرسل النبضة إذا كان القرار فتحاً. ولا يصل إليه إلا ما في شبكة الكاميرات
POST/api/vehiclesإضافة ساكن، أو زائر إذا أرسلت minutes، وتحتاج رمز الحارس في X-Guard-Token، وفي الإنتاج اسم الحارس من تسجيل الدخول
POST/api/overrideفتح يدوي بسبب من خمسة أحرف على الأقل، ويكتب في السجل باسم من فتح
GET/api/events?limit=20آخر الأحداث لصفحة الحارس، وهي تسأله كل 3 ثوان
POST/pulse على ال Relayنبضة بين 200 و2000 ملي ثانية، مع الترويسة X-Relay-Token
DELETE/api/vehicles/{plate}حذف سيارة الساكن الذي غادر، مع سطر في السجل، وهي الواجهة التي تمنع أكثر حالة ستحدث فعلاً كما سيأتي في قسم الأمان

تقدير السعة على ظهر ورقة

الأرقام التالية افتراضات لمجمع سكني متوسط، والمهم طريقة الحساب، وقد شرحنا هذا النوع من التقدير في مدخل إلى تصميم الأنظمة:

  • 400 وحدة سكنية، ولكل وحدة سيارة ونصف في المتوسط، وكل سيارة تمر أربع مرات في اليوم، ومعها 100 زائر يدخلون ويخرجون.
  • ساعة الصباح فيها 20% من حركة اليوم، والكاميرا ترسل ثلاث صور في الثانية لمدة أربع ثوان لكل سيارة.
  • بث كل كاميرا 4 ميجابت في الثانية، وهذا يختلف بحسب الكاميرا وإعدادها.
cars                  = 400 units x 1.5                    = 600 cars
passes per day        = 600 x 4 + 100 visitors x 2         = 2,600 passes
morning peak hour     = 2,600 x 20%                        = 520 passes ~ one car every 7 s
images to read        = 3 per second x 4 s                 = 12 per car
both gates at once    = 2 x 3                              = 6 images/s the server must read

events per day        = 2,600 decisions + repeats ignored  ~ 8,000 rows
events storage        = 8,000 x ~200 B                     ~ 1.6 MB/day  ~ 580 MB/year
if you kept a photo   = 2,600 x 150 KB                     ~ 390 MB/day  ~ 140 GB/year

camera streams        = 2 x 4 Mbit/s                       = 8 Mbit/s inside the site
recording 24 h        = 4 Mbit/s / 8 x 86,400 s            ~ 43 GB/day per camera

وفي الحساب أعلاه لاحظ التالي:

  • الحمل صغير جداً بلغة الأنظمة الكبيرة، فالسؤال الوحيد المهم هو: هل يقرأ سيرفرك ست صور في الثانية بالنموذجين الأكبر؟ وهذا لم نقسه في التجربة لأن الصور أرسلت واحدة بعد الأخرى، فقسه على السيرفر الذي سوف تشتريه، وإذا لم يكن كافياً فلا ترسل كل إطار، وإنما أرسل الإطار فقط عندما يكتشف حساس الحركة سيارة في مكان الوقوف.
  • السجل نفسه أقل من غيغابايت في السنة، أما حفظ صورة لكل سيارة فيضاعف المساحة مئتين وخمسين مرة تقريباً، ويحولها إلى بيانات شخصية يجب أن تبرر حفظها كما سيأتي في قسم الأنظمة، لذلك لا يحفظ جدول الأحداث في التجربة أي صورة.
  • تسجيل الكاميرات 24 ساعة هو أكبر ما في المشروع: 43 غيغابايت في اليوم لكل كاميرا بهذا الافتراض، فمدة الحفظ تحددها وثيقة الشروط والسعة معاً.

مراحل النمو

الجزءالبداية MVP (بوابة واحدة وسيرفر صغير)النمو (عدة بوابات وآلاف السيارات)الحجم الكبير (مئات المواقع وملايين القراءات)
القراءة والقرارخدمة التجربة كما هي على سيرفر صغير بجانب البوابةنفس الخدمة لكل البوابات مع جدول gates، أو القراءة من Frigate عبر MQTTجهاز صغير في كل موقع يقرر وحده ولو انقطع عن المركز، والمركز لا يفتح أي بوابة
البياناتSQLitePostgreSQL ونسخ احتياطي كل ليلةقائمة كل موقع عنده محلياً، تزامن من المركز، والأحداث ترسل إلى المركز لاحقاً
الحارس والإدارةرمز حارس واحد كما في التجربةتسجيل دخول لكل حارس عبر authentik، وصفحة للإدارةصلاحيات لكل موقع، وتقارير مركزية، وتحديث النماذج على كل الأجهزة بالتدريج
الأمانشبكة منفصلة للكاميرا وال Relayتنبيه عند تعطل الأجهزة وعند تكرار الفتح اليدويعامل ثان مثل بطاقة RFID للأماكن الحساسة

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

ماذا لو تعطل جزء؟

ماذا لو تعطل…؟الأثرالحل
الكاميرا، أو اتسخت عدستها، أو جاء الليللا توجد قراءات، أو قراءات بثقة منخفضةالحالة تذهب إلى الحارس بدل أن يفتح النظام أو يرفض وحده، والتنبيه يخبرك أن الكاميرا لا ترد، والإضاءة والغالق كما في قسم الكاميرا
سيرفر البوابةلا قراءة ولا قراروحدة التحكم في البوابة تعمل كما كانت قبل المشروع بالزر وجهاز التحكم عن بعد، فالحارس يفتح بنفسه حتى يعود السيرفر
ال Relayالسيرفر يقرر الفتح ولا ترتفع الذراعالقرار يكتب relay_error وليس open، ويظهر بالأحمر عند الحارس، كما رأيت عندما أوقفناه في التجربة
قاعدة البيانات أو القرصلا يعرف النظام السكاننسخة restic كل ليلة واستعادة مجربة، وفتح يدوي حتى تنتهي الاستعادة
الكهرباءكل شيء يتوقفUPS للسيرفر والسويتش وال Relay، وتأكد من سلوك وحدة التحكم في البوابة عند انقطاع الكهرباء من دليلها
الإنترنتلا تحديثات ولا نسخ خارج الموقع ولا تنبيهات خارجيةلا شيء في فتح البوابة يحتاجه، وهذا هو سبب أن كل شيء داخل الموقع

كيف تقرأ اللوحة السعودية؟

اللوحة السعودية الخاصة فيها صفان: الصف العلوي بالحروف العربية والأرقام الهندية (٠ ١ ٢ ٣…)، والصف السفلي بالحروف اللاتينية والأرقام العربية الغربية (0 1 2 3…)، وفي كل صف جزء للأرقام وجزء لثلاثة حروف، وبجانبهما شريط فيه كلمة السعودية وKSA. والصورة التالية للوحة حقيقية من الطراز الذي يركب على السيارات اليوم:

لوحة سيارة سعودية بيضاء من صفين: في الأعلى الأرقام ٧٤٠٣ والحروف ا و ر، وفي الأسفل 7403 وRUA، وعلى اليمين شريط عمودي فيه شعار المملكة وكلمة السعودية وKSA ودائرة سوداء
لوحة سعودية بصفين، العربي في الأعلى واللاتيني في الأسفل. الصورة: KevinRachel2010، بترخيص CC BY-SA 3.0، المصدر: Wikimedia Commons (تم تصغيرها وتحويلها إلى WebP).

وفي الصورة أعلاه لاحظ أن الحرف العربي الذي فوق R هو الحرف ا، وليس الحرف ر، وهذه أهم نقطة في المقال لمن يكتب الكود، والسبب أن الحروف العربية تقرأ من اليمين إلى اليسار والحروف اللاتينية من اليسار إلى اليمين، فأول حرف عربي (أقصى اليمين) يقابل أول حرف لاتيني (أقصى اليسار)، وبالتالي فاللوحة RUA هي «ر و ا» وليست «ا و ر». والصورة التالية تبين ذلك على لوحة سيارة من صور التجربة:

رسم للوحة سعودية: في الصف العلوي ٨٩٦٤ والحروف ا ق ص من اليسار إلى اليمين، وفي الصف السفلي 8964 وX G A، وسهم أحمر متقطع من ا إلى X مكتوب بجانبه خطأ المقابلة بالعمود، وثلاثة أسهم خضراء مرقمة من ص إلى X ومن ق إلى G ومن ا إلى A مكتوب بجانبها الصحيح بترتيب القراءة، وتحت اللوحة المفتاح 8964XGA
الحروف تتقابل بترتيب القراءة، وليس بالعمود

والحروف المستخدمة في اللوحات سبعة عشر حرفاً فقط، لكل منها حرف لاتيني ثابت، كما في الجدول التالي:

العربياللاتينيالعربياللاتينيالعربياللاتيني
اAسSلL
بBصXمZ
حJطTنN
دDعEهH
رRقGوU
كKىV

ولاحظ أن بعض المقابلات ليست كما تتوقع، فالحاء J والصاد X والعين E والقاف G والميم Z والياء V، لذلك لا تحاول أن تستنتجها من نطق الحرف، وإنما ضعها في جدول ثابت في الكود. ويجدر أن نكون واضحين هنا: لم نجد جدولاً بهذه الحروف منشوراً نصاً في صفحات المرور وأبشر التي استطعنا فتحها، فبنينا الجدول من اللوحات نفسها، وقد قابلنا أحد عشر حرفاً منها (A وB وE وG وJ وL وN وR وS وU وX) حرفاً حرفاً على اللوحات الحقيقية في صور هذا المقال، والستة الباقية من الجدول المتداول للوحات، لذلك راجعها على لوحات حقيقية في مكانك قبل أن تعتمدها في نظام يفتح بوابة.

ومن هذا كله نخرج بقاعدة واحدة للتخزين: نحفظ كل لوحة في قاعدة البيانات بمفتاح واحد هو الأرقام ثم الحروف اللاتينية بترتيبها، مثل 8964XGA، ونحول إليه كل ما يكتبه الحارس، سواءً كتب «ص ق ا ٨٩٦٤» أو «xga 8964» أو «XGA-8964»، والسبب أن نموذج القراءة الذي سنستخدمه يعرف الحروف اللاتينية فقط، وأن الحارس سوف يكتب اللوحة بالطريقة التي يراها أسهل، فإذا خزنت اللوحة بأكثر من شكل فلن تجدها عند البحث.

الكاميرا: أين تضعها وكيف تضبطها؟

الحل البسيط هو أن تستخدم كاميرا المراقبة الموجودة فوق البوابة، وهي غالباً كاميرا واسعة الزاوية تصور الساحة كلها، وفي صورتها تكون اللوحة بضع عشرات من البكسلات لا يقرؤها إنسان ولا نموذج. لذلك تحتاج البوابة كاميرا مخصصة للوحة، قريبة منها، وبزاوية صغيرة. والأرقام التالية من دليل AXIS License Plate Verifier، وهو تطبيق من Axis يعمل داخل الكاميرا ويقرأ اللوحات لنفس الغرض، والصورة التالية تجمعها:

مخطط من ثلاثة أجزاء: منظر جانبي فيه كاميرا على عمود بارتفاع A وسيارة على مسافة C وخط نظر متقطع إلى اللوحة، ومكتوب الارتفاع نصف المسافة لمسافة بين 2 و7 أمتار، ومنظر من الأعلى فيه سيارة على الطريق وكاميرا على جانبه بزاوية 30 درجة على الأكثر، وفي الأسفل لوحة بسطر واحد عرضها 130 بكسل على الأقل ولوحة بسطرين عرضها 70 بكسل على الأقل
مكان كاميرا البوابة بالأرقام من دليل Axis لحالة التحكم في الدخول
  • الارتفاع والمسافة: يذكر الدليل أن ارتفاع الكاميرا في حالة التحكم في الدخول Access Control يكون نصف المسافة بينها وبين السيارة، والمسافة في هذه الحالة بين 2 و7 أمتار، فكاميرا على بعد 4 أمتار من مكان وقوف السيارة توضع على ارتفاع مترين.
  • الزاوية: لا تزيد زاوية الكاميرا إلى اللوحة على 30 درجة في أي اتجاه، من الجانب ومن الأعلى، ولا يميل خط اللوحة في الصورة أكثر من 5 درجات، والسبب أن الحروف تضيق وتتشوه كلما زادت الزاوية حتى تتشابه 8 وB و0 وD.
  • حجم اللوحة في الصورة: يطلب الدليل أن يكون عرض اللوحة 130 بكسل على الأقل للوحة ذات السطر الواحد، و70 بكسل على الأقل للوحة ذات السطرين. وهذا حد أدنى لتطبيق Axis، وكلما كبرت اللوحة في الصورة قلت أخطاء القراءة، فاجعلها أكبر من هذا الحد ما استطعت.
  • الشمس: ينبه الدليل إلى أن الشمس المباشرة وقت الشروق والغروب قد تفسد الصورة، فلا توجه الكاميرا إلى الشرق أو الغرب إذا كان لك خيار.

سرعة الغالق والليل

السيارة لا تقف دائماً، والغالق Shutter هو المدة التي يجمع فيها الحساس الضوء لكل إطار، فإذا تحركت اللوحة خلال هذه المدة ظهرت حروفها ممسوحة Motion Blur. ولنحسبها: سيارة تقترب من الذراع بسرعة 20 كم/ساعة تقطع نحو 5.6 متر في الثانية، فإذا كان الغالق 1/30 من الثانية تحركت اللوحة نحو 18.5 سنتيمتراً خلال الإطار الواحد، أي أكثر من عرض حرفين، أما بغالق 1/1000 فتتحرك نحو 5.6 مليمتر فقط. لذلك ثبت سرعة الغالق يدوياً في إعدادات الكاميرا على قيمة قصيرة بدل الوضع التلقائي، وارجع إلى توصية الشركة المصنعة لكاميرتك في الرقم الدقيق، وتذكر أن الغالق القصير يعني ضوءاً أقل، فتحتاج إضاءة أو أشعة تحت حمراء Infrared في الليل.

وفي الليل تبقى المشكلة نفسها مع ضوء أقل، وتوثيق Frigate يقول صراحة إن القراءة ليلاً تعتمد على جودة الكاميرا والإضاءة وقدرتها بالأشعة تحت الحمراء، وإن عليك أن تتأكد أن الكاميرا تلتقط صورة واضحة للوحة في الليل. والصورة التالية لسيارة سعودية ليلاً في شارع مضاء، وقد قرأها النموذج في تجربتنا كما سيأتي، ولكن لاحظ الإضاءة القوية حولها وأن السيارة واقفة:

سيارة مرسيدس بيضاء واقفة ليلاً بجانب الرصيف في شارع في لندن، وخلفها خطوط ضوء ملونة من حافلة مرت أثناء التصوير الطويل، وعلى مقدمتها لوحة سعودية من صفين فيها ٦٦٦ س ب و وفي الأسفل 666 SBU
سيارة بلوحة سعودية ليلاً في لندن، والخطوط خلفها حافلة مرت أثناء التصوير الطويل. الصورة: Ben from LONDON، بترخيص CC BY-SA 2.0، المصدر: Wikimedia Commons (تم تصغيرها وتحويلها إلى WebP).

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

كيف يفتح الكود البوابة؟

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

  • Relay شبكي جاهز: مثل Shelly 1 Gen3 الذي تصفه الشركة بأنه مفتاح ذكي بتلامس معزول Potential-free، ويعمل على شبكة Wi-Fi المحلية دون السحابة، وواجهته البرمجية فيها الأمر Switch.Set مع الخيار toggle_after الذي يعيد المفتاح إلى حالته بعد عدد من الثواني، فتحصل على نبضة واحدة بطلب واحد.
  • لوحة ESP32 مع Relay: إذا كنت تحب البناء بنفسك، وتوثيق ESPHome فيه مثال لهذه الحالة بالضبط: Relay يقلد ضغط زر جهاز التحكم في البوابة لمدة 500 ملي ثانية ثم يعود، ويذكر التوثيق أن الزر يشغل محرك البوابة أو يوقفه فقط، ولا يعرف إن كانت ستفتح أو تغلق.

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

🛑
لا تقم بتوصيل أي شيء بكهرباء البوابة أو بمحركها بنفسك، والسبب أن وحدة التحكم فيها جهد كهربائي خطر، وفيها حساسات أمان مثل الخلايا الضوئية Photocells التي توقف الذراع إذا كان تحته شخص أو سيارة، وأي توصيل خاطئ قد يعطل هذه الحماية. اطلب من فني معتمد للبوابات أن يوصل ال Relay بمدخل الزر في وحدة التحكم حسب دليل الشركة المصنعة، واترك للوحدة نفسها قرار الحركة والتوقف، فدور الكود هو أن يضغط الزر فقط.

ولاحظ أن ال Relay يرسل نبضة ولا يبقي التلامس مغلقاً، والسبب أن التلامس المغلق دائماً قد تفهمه بعض الوحدات كأمر بالبقاء مفتوحة، فإذا تعطل الكود في تلك اللحظة بقيت البوابة مفتوحة، لذلك نحدد في الكود مدة النبضة بحد أعلى لا يتجاوزه.

أدوات اكتشاف اللوحة وقراءتها

القراءة نفسها خطوتان كما رأينا في تجربة رقم المبنى: نموذج يجد مكان اللوحة في الصورة، ثم نموذج يقرأ ما فيها. وهذه أشهر الأدوات المفتوحة المصدر لذلك اليوم:

  • fast-alpr: مكتبة Python بترخيص MIT تجمع الخطوتين، فتستخدم للاكتشاف نماذج YOLOv9 من مكتبة open-image-models، وللقراءة نماذج fast-plate-ocr المدربة على اللوحات، وكلها بصيغة ONNX تعمل على المعالج دون كرت شاشة عبر ONNX Runtime. وهي ما سنستخدمه في التجربة.
  • PaddleOCR: مكتبة OCR عامة بترخيص Apache 2.0 تذكر أنها تدعم أكثر من 100 لغة، وهي ما يستخدمه Frigate في القراءة كما سيأتي.
  • Tesseract: رأيناه في مقال سيارات المسح، وهو مصمم لقراءة صفحات النص، وله نموذج للغة العربية، ولكنه يحتاج أن تقص له اللوحة وتجهز صورتها بنفسك.
  • OpenALPR: كان الاسم الأشهر لسنوات، ولكن آخر إصدار في مستودعه المفتوح هو 2.3.0 في يوليو 2016 وآخر تعديل على الكود في 2020، وترخيصه AGPL 3.0، وموقع openalpr.com اليوم يعرض منتجات تجارية باسم Rekor، لذلك لا ينصح بأن تبني عليه مشروعاً جديداً.

إذا كان عندك Frigate: ميزة قراءة اللوحات فيه

إذا كنت تشغل Frigate لتسجيل الكاميرات، فقد أضاف الإصدار 0.16.0 في أغسطس 2025 ميزة قراءة اللوحات License Plate Recognition، وهي موجودة في الإصدار 0.18.0 الذي شرحناه. ويذكر توثيقها النقاط التالية:

  • الاكتشاف بنموذج YOLOv9 خفيف، والقراءة بنماذج PaddleOCR، وكلها تعمل محلياً، وتحتاج 4 جيجابايت ذاكرة على الأقل ومعالجاً يدعم تعليمات AVX وAVX2.
  • النموذج الصغير small وهو الافتراضي يقرأ الحروف اللاتينية والصينية، والنموذج الكبير large يقرأ اللاتينية فقط ولكنه يجد الحروف في اللوحات متعددة الأسطر، ويقول التوثيق إن من لا يستخدم بلده لوحات متعددة الأسطر يختار الصغير، واللوحة السعودية من سطرين.
  • تضع اللوحات المعروفة في known_plates مع اسم لكل منها، وmatch_distance يسمح بفرق حرف أو أكثر، وformat يقبل تعبيراً نمطياً Regex يرفض كل قراءة لا تطابق شكل لوحات بلدك.
  • هناك وضع للكاميرا المخصصة للوحات type: "lpr" عندما تكون الصورة مقربة على اللوحة ولا يظهر فيها شكل السيارة كاملاً.

والفرق بين Frigate والمشروع الذي سنبنيه أن Frigate يقرأ اللوحة ويضعها على حدث السيارة وينشرها عبر MQTT، ولكنه لا يدير قائمة سكان وتصاريح زوار وقرار حارس، فإذا أردت البوابة كاملة تحتاج خدمة مثل التي سنكتبها، تأخذ القراءة من Frigate أو تقرأ بنفسها، ثم تقرر.

كيف يقرر السيرفر فتح البوابة؟

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

مخطط قرار يبدأ بقراءة اللوحة 8964XGA بثقة 0.99، ثم خمسة أسئلة مرقمة: هل فتحت لها قبل أقل من 15 ثانية فإن نعم تجاهل، هل اللوحة في القائمة بالضبط، هل انتهى تصريح الزائر فإن نعم رفض، هل الثقة 0.90 أو أكثر فإن نعم افتح بنبضة لل Relay وإن لا اسأل الحارس، وإذا لم تكن في القائمة: هل تختلف بحرف واحد عن لوحة مسجلة فإن نعم اسأل الحارس وإن لا رفض، وصندوق يقول إن كل فرع يكتب سطراً في جدول events
خطوات القرار لكل قراءة، وكل فرع يكتب سطراً في سجل الأحداث
  • نافذة التكرار: إذا فتحنا لهذه اللوحة قبل أقل من 15 ثانية نتجاهل القراءة الجديدة، فالسيارة نفسها ما زالت أمام الكاميرا.
  • المطابقة التامة أولاً: اللوحة في القائمة بالضبط، ثم نتأكد أن تصريحها لم ينته، ثم أن ثقة القراءة 0.90 أو أكثر، وعندها فقط نفتح، وإذا كانت الثقة أقل نسأل الحارس.
  • المطابقة التقريبية Fuzzy Match تسأل ولا تفتح: إذا اختلفت القراءة عن لوحة مسجلة بحرف واحد، أي أن مسافة Levenshtein بينهما تساوي 1، نعرض الحالة على الحارس مع اسم اللوحة القريبة، ولا نفتح وحدنا، والسبب أن فرق الحرف الواحد قد يكون خطأ قراءة، وقد يكون سيارة أخرى فعلاً، فاللوحتان 9164ARA و8164ARA لسيارتين مختلفتين في الشارع.
  • كل شيء يسجل: كل فرع يكتب سطراً في جدول الأحداث بالقرار وسببه، حتى الرفض والتجاهل.

ولاحظ أن Frigate يسمح بالفرق نفسه عبر match_distance، ولكنه يستخدمه ليسمي السيارة، أما في البوابة فقرار الفتح أخطر من الاسم، لذلك جعلنا المطابقة التقريبية سؤالاً للحارس وليست فتحاً.

تجربة عملية: من صور اللوحات إلى ذراع البوابة

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

تنزيل الصور وتشغيل النموذج

نبدأ بمجلد التجربة وتنزيل الصور من Commons بأسماء قصيرة:

mkdir -p plate-lab/images && cd plate-lab
while IFS=: read -r file name; do
  curl -sL -A "plate-lab/1.0" -o "images/$name" "https://commons.wikimedia.org/wiki/Special:FilePath/$file"
  sleep 2
done <<'EOF'
Camry_2009_1.jpg:sa-camry-front.jpg
Camry_2009_3.jpg:sa-camry-rear.jpg
Night_(7603395394).jpg:sa-night.jpg
Gumball_3000_2011_London_72.jpg:sa-gumball.jpg
Saudi_Arabia_license_plate_KSA.jpg:sa-plate-ksa.jpg
Saudi_Arabia_license_plate_2014_North_American_size.JPG:sa-plate-2014-na.jpg
Saudi_Arabia_licenceplate_3.JPG:sa-plate-3.jpg
Saudi_Arabia_licenceplate_1.JPG:sa-plate-1.jpg
Saudi_Arabia_licence_plate_right_side_KSA_pre-2014.jpg:sa-plate-pre2014.jpg
Saudi_Arabia_license_plate_2014_European_size.jpg:sa-plate-2014-eu.jpg
J_8654_2010_Chevrolet_Camaro_SS_(7375532122).jpg:ae-camaro.jpg
A_43612_-_Ras_Al_Khaimah_(UAE)_License_Plate.jpg:ae-rak.jpg
EOF

ثم ملف الإجابات truth.json، وهو النص المكتوب على كل لوحة كما قرأناه من الصورة، الأرقام ثم الحروف اللاتينية للوحات السعودية:

{
  "sa-camry-front.jpg": "8964XGA",
  "sa-camry-rear.jpg": "8964XGA",
  "sa-night.jpg": "666SBU",
  "sa-gumball.jpg": "1GNG",
  "sa-plate-ksa.jpg": "6251EJA",
  "sa-plate-2014-na.jpg": "7403RUA",
  "sa-plate-3.jpg": "6878RRJ",
  "sa-plate-1.jpg": "9164ARA",
  "sa-plate-pre2014.jpg": "0528LUX",
  "sa-plate-2014-eu.jpg": "4734AGJ",
  "ae-camaro.jpg": "J8654",
  "ae-rak.jpg": "A43612"
}

والسكربت recognize.py يمرر كل صورة على المكتبة ويقارن النتيجة بالإجابة:

import json
import statistics
import sys

from fast_alpr import ALPR

DETECTOR = sys.argv[1] if len(sys.argv) > 1 else "yolo-v9-t-384-license-plate-end2end"
OCR = sys.argv[2] if len(sys.argv) > 2 else "cct-xs-v2-global-model"

# the text written on each plate, read by a person from the photo
TRUTH = json.load(open("truth.json", encoding="utf-8"))

alpr = ALPR(detector_model=DETECTOR, ocr_model=OCR)
print(f"detector={DETECTOR} ocr={OCR}")
for name, expected in TRUTH.items():
    results = alpr.predict(f"images/{name}")
    if not results:
        print(f"{name:24} expected={expected:9} MISSED")
        continue
    best = max(results, key=lambda r: r.detection.confidence)
    text = best.ocr.text if best.ocr else ""
    conf = best.ocr.confidence if best.ocr else 0
    if isinstance(conf, list):
        conf = statistics.mean(conf) if conf else 0
    verdict = "OK" if text == expected else "WRONG"
    print(f"{name:24} expected={expected:9} read={text:10} det={best.detection.confidence:.2f} "
          f"ocr={conf:.2f} {verdict}")

نشغل Container من Docker Image الرسمية لبايثون على المجلد، ونثبت فيه المكتبة بإصدار ثابت:

docker run -d --name lab-plate-alpr -v "$PWD:/lab" -w /lab python:3.13.16-slim sleep infinity
docker exec lab-plate-alpr pip install -q "fast-alpr[onnx]==0.4.0"
docker exec lab-plate-alpr python recognize.py

وفي أول تشغيل تنزل المكتبة النموذجين من GitHub (نحو 7.4 ميغابايت للاكتشاف و3.2 للقراءة)، والمخرج سوف يكون كما يلي:

detector=yolo-v9-t-384-license-plate-end2end ocr=cct-xs-v2-global-model
sa-camry-front.jpg       expected=8964XGA   read=8914BGA    det=0.86 ocr=0.71 WRONG
sa-camry-rear.jpg        expected=8964XGA   read=811588     det=0.91 ocr=0.73 WRONG
sa-night.jpg             expected=666SBU    read=111SBJ     det=0.87 ocr=0.53 WRONG
sa-gumball.jpg           expected=1GNG      read=1333GNG    det=0.88 ocr=0.83 WRONG
sa-plate-ksa.jpg         expected=6251EJA   read=62511A     det=0.95 ocr=0.72 WRONG
sa-plate-2014-na.jpg     expected=7403RUA   read=Y4F337     det=0.82 ocr=0.67 WRONG
sa-plate-3.jpg           expected=6878RRJ   read=TAYX383    det=0.88 ocr=0.64 WRONG
sa-plate-1.jpg           expected=9164ARA   MISSED
sa-plate-pre2014.jpg     expected=0528LUX   MISSED
sa-plate-2014-eu.jpg     expected=4734AGJ   read=4734AGJ    det=0.70 ocr=0.90 OK
ae-camaro.jpg            expected=J8654     read=8654       det=0.90 ocr=1.00 WRONG
ae-rak.jpg               expected=A43612    read=AA33622    det=0.86 ocr=0.70 WRONG

صورة واحدة صحيحة من 12، وتسع قراءات خطأ، وصورتان لم يجد فيهما النموذج لوحة أصلاً. والعمود det هو ثقة الاكتشاف، والعمود ocr هو متوسط ثقة القراءة لكل حرف. وقبل أن نحكم على المكتبة نجرب النموذجين الأكبر، فنموذج الاكتشاف yolo-v9-s-608 يرى الصورة بدقة 608 بدل 384، ونموذج القراءة cct-s-v2 أكبر من cct-xs:

docker exec lab-plate-alpr python recognize.py yolo-v9-s-608-license-plate-end2end cct-s-v2-global-model

والمخرج سوف يكون كما يلي:

detector=yolo-v9-s-608-license-plate-end2end ocr=cct-s-v2-global-model
sa-camry-front.jpg       expected=8964XGA   read=A111J9644  det=0.92 ocr=0.52 WRONG
sa-camry-rear.jpg        expected=8964XGA   read=A11199     det=0.91 ocr=0.57 WRONG
sa-night.jpg             expected=666SBU    read=11199U     det=0.90 ocr=0.54 WRONG
sa-gumball.jpg           expected=1GNG      read=11GGG      det=0.91 ocr=0.80 WRONG
sa-plate-ksa.jpg         expected=6251EJA   read=TT6125     det=0.95 ocr=0.70 WRONG
sa-plate-2014-na.jpg     expected=7403RUA   read=Y1T7403    det=0.97 ocr=0.78 WRONG
sa-plate-3.jpg           expected=6878RRJ   read=TAYAS878   det=0.96 ocr=0.71 WRONG
sa-plate-1.jpg           expected=9164ARA   read=11111J1    det=0.77 ocr=0.69 WRONG
sa-plate-pre2014.jpg     expected=0528LUX   read=91AJ991    det=0.90 ocr=0.57 WRONG
sa-plate-2014-eu.jpg     expected=4734AGJ   read=1734AG     det=0.82 ocr=0.64 WRONG
ae-camaro.jpg            expected=J8654     read=J8654      det=0.88 ocr=1.00 OK
ae-rak.jpg               expected=A43612    read=A43612     det=0.92 ocr=1.00 OK

في المخرج أعلاه لاحظ التالي:

  • النموذج الأكبر وجد اللوحة في الصور الاثنتي عشرة كلها، وثقة الاكتشاف بين 0.77 و0.97، أي أن الاكتشاف لم يعد المشكلة.
  • اللوحتان الإماراتيتان، وهما بسطر واحد، قرئتا صحيحتين بثقة 1.00.
  • اللوحات السعودية العشر كلها خطأ، وفي القراءات أرقام لا وجود لها مثل 111 و11199، والسبب أن النموذج يقرأ اللوحة كاملة بصفيها، فيحاول قراءة الأرقام الهندية والحروف العربية في الصف العلوي كأنها لاتينية، فالرقم ٦ يشبه عنده 1، والحرف ا يشبه 1 أيضاً.

ولماذا يحدث هذا؟ لأن ملف إعداد نموذج القراءة يحدد الحروف التي يعرفها بالأرقام من 0 إلى 9 والحروف اللاتينية من A إلى Z فقط، وقائمة الدول التي تدرب النموذج على لوحاتها فيها 65 دولة ليست السعودية منها. لذلك فالحل الأنسب ليس أن نبحث عن نموذج أكبر، وإنما أن نعطي النموذج ما يعرفه: الصف اللاتيني فقط.

نقرأ الصف اللاتيني فقط

بعد أن يجد النموذج صندوق اللوحة، نقص النصف السفلي منه (من 48% من ارتفاعه إلى أسفله) ونعطيه لنموذج القراءة، والصورة التالية تبين صندوق الاكتشاف وخط القص على إحدى صور التجربة:

مقدمة سيارة تويوتا كامري فضية، وحول لوحتها صندوق برتقالي رسمه نموذج الاكتشاف، وفي منتصف الصندوق خط أخضر أفقي يفصل الصف العربي ٨٩٦٤ ا ق ص في الأعلى عن الصف اللاتيني 8964 XGA في الأسفل
صندوق الاكتشاف حول اللوحة، وتحت الخط الأخضر الصف الذي نقرؤه. الصورة الأصلية: Ironman322، بترخيص CC BY-SA 4.0، المصدر: Wikimedia Commons (قصصناها ورسمنا عليها الصندوق والخط، وهي منشورة بالترخيص نفسه).

والسكربت recognize_rows.py يفعل ذلك للوحات السعودية، ويطبع القراءتين معاً للمقارنة:

import json
import statistics
import sys

import cv2
from fast_alpr import ALPR

DETECTOR = sys.argv[1] if len(sys.argv) > 1 else "yolo-v9-s-608-license-plate-end2end"
OCR = sys.argv[2] if len(sys.argv) > 2 else "cct-s-v2-global-model"
TRUTH = json.load(open("truth.json", encoding="utf-8"))

alpr = ALPR(detector_model=DETECTOR, ocr_model=OCR)


def read(img):
    r = alpr.ocr.predict(img)
    conf = r.confidence
    if isinstance(conf, list):
        conf = statistics.mean(conf) if conf else 0
    return r.text, conf


print(f"detector={DETECTOR} ocr={OCR}")
for name, expected in TRUTH.items():
    img = cv2.imread(f"images/{name}")
    dets = alpr.detector.predict(img)
    if not dets:
        print(f"{name:24} expected={expected:9} MISSED")
        continue
    d = max(dets, key=lambda x: x.confidence).bounding_box
    plate = img[max(d.y1, 0):d.y2, max(d.x1, 0):d.x2]
    cv2.imwrite(f"crops/{name}", plate)
    whole, c1 = read(plate)
    text = whole
    if name.startswith("sa-"):
        # Saudi plates have two rows: Arabic on top, Latin letters and Western digits below
        h = plate.shape[0]
        bottom = plate[int(h * 0.48):, :]
        text, c1 = read(bottom)
    verdict = "OK" if text == expected else "WRONG"
    print(f"{name:24} expected={expected:9} whole={whole:10} latin_row={text if name.startswith('sa-') else '-':10} "
          f"ocr={c1:.2f} {verdict}")
mkdir -p crops
docker exec lab-plate-alpr python recognize_rows.py

والمخرج سوف يكون كما يلي:

detector=yolo-v9-s-608-license-plate-end2end ocr=cct-s-v2-global-model
sa-camry-front.jpg       expected=8964XGA   whole=A111J9644  latin_row=8964XGA    ocr=0.99 OK
sa-camry-rear.jpg        expected=8964XGA   whole=A11199     latin_row=8964XGA    ocr=1.00 OK
sa-night.jpg             expected=666SBU    whole=11199U     latin_row=666SBU     ocr=0.91 OK
sa-gumball.jpg           expected=1GNG      whole=11GGG      latin_row=1GNG       ocr=1.00 OK
sa-plate-ksa.jpg         expected=6251EJA   whole=TT6125     latin_row=6251EJA    ocr=1.00 OK
sa-plate-2014-na.jpg     expected=7403RUA   whole=Y1T7403    latin_row=7403RUA    ocr=1.00 OK
sa-plate-3.jpg           expected=6878RRJ   whole=TAYAS878   latin_row=6878RRJ    ocr=1.00 OK
sa-plate-1.jpg           expected=9164ARA   whole=11111J1    latin_row=8164ARA    ocr=0.91 WRONG
sa-plate-pre2014.jpg     expected=0528LUX   whole=91AJ991    latin_row=0523LUX    ocr=1.00 WRONG
sa-plate-2014-eu.jpg     expected=4734AGJ   whole=1734AG     latin_row=4734AGG    ocr=0.92 WRONG
ae-camaro.jpg            expected=J8654     whole=J8654      latin_row=-          ocr=1.00 OK
ae-rak.jpg               expected=A43612    whole=A43612     latin_row=-          ocr=1.00 OK

والجدول التالي يجمع النتائج الثلاث على الصور الاثنتي عشرة:

الإعدادصحيحةخطألم يجد لوحة
النموذجان الصغيران، اللوحة كاملة192
النموذجان الأكبر، اللوحة كاملة2100
النموذجان الأكبر، الصف اللاتيني للوحات السعودية930

في النتيجة الأخيرة لاحظ التالي:

  • سبع لوحات سعودية من عشر صحيحة، ومنها صورة الليل وصورة السيارة في الزحام، بعد أن كانت صفراً من عشر.
  • الأخطاء الثلاثة كلها حرف واحد: 9 قرئت 8، و8 قرئت 3، وJ قرئت G، وكلها في لوحات طويلة بسطرين من الطراز الأوروبي، وهذا يفسر لماذا جعلنا المطابقة التقريبية سؤالاً للحارس.
  • والأخطر أن القراءة الخطأ 0523LUX جاءت بثقة 1.00، أي أن النموذج قد يكون واثقاً تماماً وهو مخطئ، لذلك لا تجعل الثقة وحدها حارساً على البوابة.
  • هذه 12 صورة فقط التقطها أشخاص مختلفون بكاميرات مختلفة، وليست مقياساً لدقة المكتبة، ولكنها تكفي لترى أين تخطئ، وقبل تشغيل بوابة حقيقية سجل مئات الصور من كاميرتك أنت في النهار والليل واحسب الدقة عليها بالطريقة نفسها.

الخدمة: القائمة والقرار وال Relay

الآن نبني الخدمة كاملة في مجلد gate داخل مجلد التجربة. نبدأ بالملف plates.py الذي يحول أي كتابة للوحة إلى المفتاح الواحد، ويحتوي جدول الحروف السبعة عشر:

"""Saudi plate text: normalisation, Arabic and Latin forms, and edit distance."""
import re

# the 17 letters used on Saudi plates and the Latin letter printed under each one
AR_TO_LATIN = {
    "ا": "A", "ب": "B", "ح": "J", "د": "D", "ر": "R", "س": "S", "ص": "X", "ط": "T", "ع": "E",
    "ق": "G", "ك": "K", "ل": "L", "م": "Z", "ن": "N", "ه": "H", "و": "U", "ى": "V",
}
LATIN_TO_AR = {v: k for k, v in AR_TO_LATIN.items()}
# what people type on a keyboard instead of the plate letter
AR_TO_LATIN.update({"أ": "A", "إ": "A", "آ": "A", "ي": "V", "ة": "H"})

AR_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "01234567890123456789")
SAUDI = re.compile(r"^(\d{1,4})([ABJDRSXTEGKLZNHUV]{3})$")


def normalize(text: str) -> str:
    """'ص ق ا ٨٩٦٤' or 'xga-8964' or '8964 XGA' -> '8964XGA' (digits, then letters in reading order)."""
    text = text.translate(AR_DIGITS).upper()
    digits = "".join(c for c in text if c.isdigit())
    letters = "".join(AR_TO_LATIN.get(c, c) for c in text if c.isalpha() and c != "ـ")
    return digits + letters


def is_saudi(key: str) -> bool:
    return bool(SAUDI.match(key))


def arabic(key: str) -> str:
    """'8964XGA' -> 'ص ق ا ٨٩٦٤' as it is read on the top row of the plate."""
    m = SAUDI.match(key)
    if not m:
        return ""
    digits = m.group(1).translate(str.maketrans("0123456789", "٠١٢٣٤٥٦٧٨٩"))
    return " ".join(LATIN_TO_AR[c] for c in m.group(2)) + " " + digits


def distance(a: str, b: str) -> int:
    """Levenshtein distance: how many characters to change, add or remove to turn a into b."""
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]

في الكود أعلاه لاحظ التالي:

  • normalize تأخذ الأرقام بترتيبها ثم الحروف بترتيبها في النص، وبما أن من يكتب بالعربي يكتب الحروف بترتيب قراءتها «ص ق ا»، فهي تتحول إلى XGA بالترتيب الصحيح الذي رأيناه في اللوحة.
  • أضفنا إلى الجدول الحروف التي يكتبها الناس بدل حرف اللوحة: أ وإ وآ بدل ا، وي بدل ى، وة بدل ه، والسبب أن لوحة المفاتيح لا تفرق كما تفرق اللوحة.
  • SAUDI هو شكل اللوحة السعودية: من رقم إلى أربعة أرقام، ثم ثلاثة حروف من السبعة عشر فقط، فكل قراءة فيها حرف مثل O أو W ليست لوحة سعودية صحيحة.

ثم ملف الخدمة app.py، وفيه الاكتشاف والقراءة والقرار وقاعدة البيانات وال API:

import os
import sqlite3
import statistics
from datetime import datetime, timedelta, timezone

import cv2
import httpx
import numpy as np
from fast_alpr import ALPR
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
from fastapi.responses import HTMLResponse

from plates import arabic, distance, is_saudi, normalize

DB = os.environ.get("GATE_DB", "/data/gate.db")
RELAY_URL = os.environ["RELAY_URL"]
RELAY_TOKEN = os.environ["RELAY_TOKEN"]
GUARD_TOKEN = os.environ["GUARD_TOKEN"]
MIN_CONF = float(os.environ.get("MIN_CONF", "0.90"))
REPEAT_SECONDS = 15

alpr = ALPR(detector_model="yolo-v9-s-608-license-plate-end2end", ocr_model="cct-s-v2-global-model")
app = FastAPI(title="plate-gate")


def db():
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    return con


with db() as con:
    con.executescript("""
    CREATE TABLE IF NOT EXISTS vehicles (
      plate TEXT PRIMARY KEY, owner TEXT NOT NULL, unit TEXT NOT NULL,
      kind TEXT NOT NULL CHECK (kind IN ('resident', 'visitor')),
      valid_until TEXT, added_by TEXT NOT NULL, added_at TEXT NOT NULL);
    CREATE TABLE IF NOT EXISTS events (
      id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, camera TEXT NOT NULL,
      plate_read TEXT, confidence REAL, plate_matched TEXT,
      decision TEXT NOT NULL, reason TEXT NOT NULL, actor TEXT NOT NULL);
    """)


def now():
    return datetime.now(timezone.utc)


def guard(token):
    if token != GUARD_TOKEN:
        raise HTTPException(401, "guard token required")


def log(con, camera, plate_read, conf, matched, decision, reason, actor="system"):
    cur = con.execute(
        "INSERT INTO events (at, camera, plate_read, confidence, plate_matched, decision, reason, actor)"
        " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
        (now().isoformat(timespec="seconds"), camera, plate_read, conf, matched, decision, reason, actor))
    return cur.lastrowid


def pulse_relay():
    """Ask the relay for one short pulse. Returns None on success, or the error text."""
    try:
        r = httpx.post(f"{RELAY_URL}/pulse", json={"ms": 800}, headers={"X-Relay-Token": RELAY_TOKEN}, timeout=3)
        r.raise_for_status()
        return None
    except httpx.HTTPError as e:
        return f"relay error: {type(e).__name__}"


def read_plate(img):
    """Detect the plate, then read it. Saudi plates: read the Latin row only."""
    dets = alpr.detector.predict(img)
    if not dets:
        return None, 0.0
    box = max(dets, key=lambda d: d.confidence).bounding_box
    plate = img[max(box.y1, 0):box.y2, max(box.x1, 0):box.x2]
    candidates = []
    for crop in (plate[int(plate.shape[0] * 0.48):, :], plate):
        r = alpr.ocr.predict(crop)
        conf = statistics.mean(r.confidence) if isinstance(r.confidence, list) else r.confidence
        candidates.append((normalize(r.text), float(conf)))
    saudi = [c for c in candidates if is_saudi(c[0])]
    return saudi[0] if saudi else candidates[1]


def decide(con, key, conf):
    """Return (decision, matched plate, reason). decision: open / ask_guard / deny / ignore."""
    t = now()
    last = con.execute(
        "SELECT at FROM events WHERE plate_matched = ? AND decision IN ('open', 'override') ORDER BY id DESC LIMIT 1",
        (key,)).fetchone()
    if last and t - datetime.fromisoformat(last["at"]) < timedelta(seconds=REPEAT_SECONDS):
        return "ignore", key, "same car seen again within %d s" % REPEAT_SECONDS
    row = con.execute("SELECT * FROM vehicles WHERE plate = ?", (key,)).fetchone()
    if row:
        if row["valid_until"] and datetime.fromisoformat(row["valid_until"]) < t:
            return "deny", key, "visitor pass expired at " + row["valid_until"]
        if conf < MIN_CONF:
            return "ask_guard", key, f"on the list but confidence {conf:.2f} < {MIN_CONF}"
        return "open", key, f"{row['kind']} {row['owner']} unit {row['unit']}"
    close = [r["plate"] for r in con.execute("SELECT plate FROM vehicles") if distance(r["plate"], key) == 1]
    if close:
        return "ask_guard", close[0], f"not on the list, one character away from {close[0]}"
    return "deny", None, "not on the list"


@app.post("/api/camera/{camera}")
async def camera_event(camera: str, image: UploadFile = File(...)):
    img = cv2.imdecode(np.frombuffer(await image.read(), np.uint8), cv2.IMREAD_COLOR)
    key, conf = read_plate(img)
    with db() as con:
        if not key:
            log(con, camera, None, 0, None, "deny", "no plate found")
            return {"decision": "deny", "reason": "no plate found"}
        decision, matched, reason = decide(con, key, conf)
        if decision == "open" and (err := pulse_relay()):
            decision, reason = "relay_error", f"{reason} | {err}"
        event = log(con, camera, key, round(conf, 2), matched, decision, reason)
    return {"event": event, "read": key, "arabic": arabic(key), "confidence": round(conf, 2),
            "decision": decision, "matched": matched, "reason": reason}


@app.post("/api/vehicles")
def add_vehicle(plate: str = Form(...), owner: str = Form(...), unit: str = Form(...),
                minutes: int | None = Form(None), x_guard_token: str = Header(None),
                x_guard_name: str = Header("guard")):
    guard(x_guard_token)
    key = normalize(plate)
    if not is_saudi(key):
        raise HTTPException(422, f"{key} is not a Saudi plate (1-4 digits and 3 of the 17 plate letters)")
    until = (now() + timedelta(minutes=minutes)).isoformat(timespec="seconds") if minutes else None
    with db() as con:
        con.execute("INSERT OR REPLACE INTO vehicles VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (key, owner, unit, "visitor" if minutes else "resident", until, x_guard_name,
                     now().isoformat(timespec="seconds")))
        log(con, "-", key, None, key, "list_add", f"{owner} unit {unit} until {until or 'no end'}", x_guard_name)
    return {"plate": key, "arabic": arabic(key), "valid_until": until}


@app.post("/api/override")
def override(reason: str = Form(...), plate: str = Form(""), x_guard_token: str = Header(None),
             x_guard_name: str = Header("guard")):
    guard(x_guard_token)
    if len(reason.strip()) < 5:
        raise HTTPException(422, "write why the gate was opened")
    err = pulse_relay()
    decision = "relay_error" if err else "override"
    with db() as con:
        event = log(con, "manual", normalize(plate) or None, None, normalize(plate) or None,
                    decision, f"{reason} | {err}" if err else reason, x_guard_name)
    return {"event": event, "decision": decision}


@app.get("/api/events")
def events(limit: int = 20):
    with db() as con:
        return [dict(r) for r in con.execute("SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,))]


PAGE = open(os.path.join(os.path.dirname(__file__), "guard.html"), encoding="utf-8").read()


@app.get("/", response_class=HTMLResponse)
def page():
    return PAGE

في الكود أعلاه لاحظ التالي:

  • read_plate تقرأ الصف السفلي واللوحة كاملة، ثم تختار القراءة التي تطابق شكل اللوحة السعودية، وإذا لم تطابق أي منهما ترجع قراءة اللوحة كاملة، وهذا ما يجعل اللوحات الإماراتية ذات السطر الواحد تقرأ أيضاً.
  • decide هي خطوات المخطط أعلاه بالترتيب نفسه، ولا تفتح إلا في حالة واحدة: مطابقة تامة، وتصريح ساري، وثقة 0.90 أو أكثر.
  • إضافة سيارة إلى القائمة والفتح اليدوي يحتاجان رمز الحارس في الترويسة X-Guard-Token، واسم الحارس في X-Guard-Name يكتب في السجل، والفتح اليدوي يرفض إذا لم يكتب الحارس سبباً من خمسة أحرف على الأقل.
  • جدول events لا يعدل فيه الكود أي سطر ولا يحذفه، وإنما يضيف فقط، وإذا أردت أن تكشف من يعدل فيه من خارج الكود فقد شرحنا سلسلة الهاش والجدول الذي لا يقبل إلا الإضافة في مقال سجل التدقيق Audit Trail.
  • إذا لم يرد ال Relay تكتب الخدمة القرار relay_error بدل open، وسوف نرى لماذا في قسم كسر الإعداد.

ثم ال Relay الوهمي relay.py، وهو يقف مكان Relay حقيقي مثل Shelly أو ESP32:

"""Stands in for the network relay wired to the gate controller's dry-contact input."""
import os
from datetime import datetime, timezone

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

TOKEN = os.environ["RELAY_TOKEN"]
app = FastAPI(title="mock-relay")
pulses = []


class Pulse(BaseModel):
    ms: int = Field(800, ge=200, le=2000)  # a short pulse, like a push button; never a held contact


@app.post("/pulse")
def pulse(p: Pulse, x_relay_token: str = Header(None)):
    if x_relay_token != TOKEN:
        raise HTTPException(401, "bad relay token")
    at = datetime.now(timezone.utc).isoformat(timespec="seconds")
    pulses.append({"at": at, "ms": p.ms})
    print(f"{at} RELAY contact closed for {p.ms} ms -> gate controller START input", flush=True)
    return {"ok": True, "at": at}


@app.get("/log")
def log():
    return pulses

ولاحظ أن مدة النبضة محصورة بين 200 و2000 ملي ثانية في تعريف ال API نفسه، فحتى لو أخطأ الكود الذي يطلبها لا يستطيع أن يبقي التلامس مغلقاً.

ثم صفحة الحارس guard.html، وهي تقرأ آخر الأحداث كل 3 ثوان وتعرض اللوحة بالعربي واللاتيني وأي سيارة تنتظر قراره:

<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<title>بوابة السكن: شاشة الحارس</title>
<style>
  body { font-family: system-ui, "Segoe UI", Tahoma, sans-serif; margin: 0; background: #f4f5f7; color: #1b1d22; }
  header { background: #1b1d22; color: #fff; padding: 14px 24px; display: flex; justify-content: space-between; align-items: center; }
  header h1 { font-size: 20px; margin: 0; }
  main { padding: 20px 24px; }
  .pending { background: #fff4d6; border: 2px solid #f5b301; border-radius: 10px; padding: 12px 16px; margin-bottom: 16px; }
  table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; }
  th, td { padding: 10px 12px; text-align: right; border-bottom: 1px solid #e3e5ea; font-size: 15px; }
  th { background: #e9ebef; }
  .plate { font-family: Consolas, monospace; direction: ltr; unicode-bidi: isolate; font-weight: 700; }
  .open, .override { color: #1f8a55; font-weight: 700; }
  .deny, .relay_error { color: #c62828; font-weight: 700; }
  .ask_guard { color: #b26a00; font-weight: 700; }
  .ignore, .list_add { color: #5f6470; }
  button { background: #e95420; color: #fff; border: 0; border-radius: 8px; padding: 10px 18px; font-size: 15px; cursor: pointer; }
</style>
</head>
<body>
<header><h1>بوابة السكن: آخر الأحداث</h1><button id="manual">فتح يدوي مع سبب</button></header>
<main>
  <div id="pending"></div>
  <table>
    <thead><tr><th>#</th><th>الوقت (UTC)</th><th>الكاميرا</th><th>اللوحة المقروءة</th><th>بالعربي</th><th>الثقة</th><th>القرار</th><th>السبب</th><th>بواسطة</th></tr></thead>
    <tbody id="rows"></tbody>
  </table>
</main>
<script>
const LABEL = { open: 'فتح', deny: 'رفض', ask_guard: 'بانتظار الحارس', override: 'فتح يدوي', relay_error: 'تعطل الـ Relay', ignore: 'تكرار', list_add: 'إضافة للقائمة' };
const AR = { A: 'ا', B: 'ب', J: 'ح', D: 'د', R: 'ر', S: 'س', X: 'ص', T: 'ط', E: 'ع', G: 'ق', K: 'ك', L: 'ل', Z: 'م', N: 'ن', H: 'ه', U: 'و', V: 'ى' };
const arabic = p => { const m = /^(\d{1,4})([A-Z]{3})$/.exec(p || ''); return m ? [...m[2]].map(c => AR[c]).join(' ') + ' ' + m[1].replace(/\d/g, d => '٠١٢٣٤٥٦٧٨٩'[d]) : ''; };
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
async function load() {
  const events = await (await fetch('/api/events?limit=15')).json();
  document.getElementById('rows').innerHTML = events.map(e => `<tr>
    <td>${e.id}</td><td>${esc(e.at.slice(11, 19))}</td><td>${esc(e.camera)}</td>
    <td class="plate">${esc(e.plate_read)}</td><td>${arabic(e.plate_read)}</td><td>${e.confidence ?? ''}</td>
    <td class="${esc(e.decision)}">${LABEL[e.decision] || esc(e.decision)}</td>
    <td>${esc(e.reason)}</td><td>${esc(e.actor)}</td></tr>`).join('');
  // waiting for the guard: asked, and no open or override for that plate after it
  const ask = events.find((e, i) => e.decision === 'ask_guard' &&
    !events.slice(0, i).some(x => ['open', 'override'].includes(x.decision) && x.plate_matched === e.plate_matched));
  document.getElementById('pending').innerHTML = ask
    ? `<div class="pending">السيارة <span class="plate">${esc(ask.plate_read)}</span> تنتظر قرارك: ${esc(ask.reason)}</div>` : '';
}
document.getElementById('manual').onclick = async () => {
  const reason = prompt('لماذا تفتح البوابة يدوياً؟');
  const token = prompt('رمز الحارس');
  if (!reason || !token) return;
  const body = new URLSearchParams({ reason });
  await fetch('/api/override', { method: 'POST', body, headers: { 'X-Guard-Token': token } });
  load();
};
load();
setInterval(load, 3000);
</script>
</body>
</html>

ثم ملف Dockerfile، وفيه ننزل النموذجين أثناء البناء حتى تبدأ الخدمة دون إنترنت:

FROM python:3.13.16-slim
RUN pip install --no-cache-dir "fast-alpr[onnx]==0.4.0" "fastapi==0.143.0" "uvicorn==0.54.0" \
    "python-multipart==0.0.32" "httpx==0.28.1"
# download the two ONNX models at build time, so the gate does not need the internet to start
RUN python -c "from fast_alpr import ALPR; ALPR(detector_model='yolo-v9-s-608-license-plate-end2end', ocr_model='cct-s-v2-global-model')"
WORKDIR /app
COPY plates.py app.py relay.py guard.html ./

وأخيراً compose.yaml الذي يشغل الخدمة وال Relay الوهمي من نفس ال Image:

name: lab-plate
services:
  api:
    build: .
    image: lab-plate-gate:1
    container_name: lab-plate-api
    command: uvicorn app:app --host 0.0.0.0 --port 8000
    environment:
      RELAY_URL: http://relay:8000
      RELAY_TOKEN: change-me-relay-token
      GUARD_TOKEN: change-me-guard-token
      MIN_CONF: "0.90"
    volumes:
      - data:/data
    ports:
      - "127.0.0.1:18490:8000"
    networks: [gate]
  relay:
    image: lab-plate-gate:1
    container_name: lab-plate-relay
    command: uvicorn relay:app --host 0.0.0.0 --port 8000
    environment:
      RELAY_TOKEN: change-me-relay-token
    ports:
      - "127.0.0.1:18491:8000"
    networks: [gate]
volumes:
  data:
    name: lab-plate-data
networks:
  gate:
    name: lab-plate-net

في الإعداد أعلاه لاحظ التالي:

  • المنفذان مربوطان بالعنوان 127.0.0.1 فقط، فلا يصل إليهما أحد من الشبكة أثناء التجربة.
  • الرموز change-me-… للتجربة فقط، وفي البوابة الحقيقية ولد رموزاً عشوائية بالأمر openssl rand -hex 32 وضعها في ملف .env.
  • ال Relay الوهمي منشور على المنفذ 18491 حتى نقرأ سجله من الخارج، أما ال Relay الحقيقي فيجب أن يكون في شبكة لا يصل إليها إلا سيرفر البوابة، لأن من يصل إليه يفتح البوابة بطلب واحد.
  • صفحة الحارس وقائمة الأحداث في التجربة دون تسجيل دخول، وفي البوابة الحقيقية ضعها خلف تسجيل دخول كما شرحنا في حماية أي تطبيق باستخدام authentik Forward Auth.

نبني ال Image ونشغل الخدمتين:

cd gate
docker compose up -d --build
cd ..

الحارس يضيف السيارات، والكاميرا ترسل الصور

نضيف ثلاثة سكان وزائراً بتصريح مدته دقيقة واحدة، ونجرب لوحة فيها حرف ليس من حروف اللوحات، وطلباً بدون رمز الحارس. ولاحظ أن الساكن الأول نكتبه بالعربي كما يكتبه الحارس:

G=http://127.0.0.1:18490
H='X-Guard-Token: change-me-guard-token'
add(){ curl -s -X POST $G/api/vehicles -H "$H" -H 'X-Guard-Name: noura' "$@"; echo; }
add --data-urlencode 'plate=ص ق ا ٨٩٦٤' -d owner=resident-A -d unit=12
add -d plate='6251 EJA' -d owner=resident-B -d unit=7
add -d plate='ARA-9164' -d owner=resident-C -d unit=31
add -d plate='666 SBU' -d owner=visitor-plumber -d unit=12 -d minutes=1
add -d plate='6251 EJO' -d owner=test -d unit=1
curl -s -X POST $G/api/vehicles -d plate='6878 RRJ' -d owner=test -d unit=1; echo

والمخرج سوف يكون كما يلي:

{"plate":"8964XGA","arabic":"ص ق ا ٨٩٦٤","valid_until":null}
{"plate":"6251EJA","arabic":"ع ح ا ٦٢٥١","valid_until":null}
{"plate":"9164ARA","arabic":"ا ر ا ٩١٦٤","valid_until":null}
{"plate":"666SBU","arabic":"س ب و ٦٦٦","valid_until":"2026-10-09T08:21:21+00:00"}
{"detail":"6251EJO is not a Saudi plate (1-4 digits and 3 of the 17 plate letters)"}
{"detail":"guard token required"}

ثلاث كتابات مختلفة للوحات تحولت كلها إلى المفتاح نفسه، واللوحة 6251EJO رفضت لأن O ليس من حروف اللوحات، والطلب الأخير رفض لأنه بدون رمز الحارس. الآن نرسل صور السيارات إلى الخدمة كأنها من الكاميرا، بالترتيب: سيارة الساكن الأول من الأمام ثم من الخلف مباشرة، ثم الساكن الثاني، ثم الساكن الثالث في الصورة التي أخطأ النموذج في قراءتها، ثم سيارة ليست في القائمة، ثم الزائر، ثم السيارة الإماراتية:

cam(){ curl -s -X POST $G/api/camera/gate-in -F image=@images/$1; echo; }
cam sa-camry-front.jpg; cam sa-camry-rear.jpg; cam sa-plate-ksa.jpg; cam sa-plate-1.jpg
cam sa-plate-3.jpg; cam sa-night.jpg; cam ae-camaro.jpg

والمخرج سوف يكون كما يلي:

{"event":5,"read":"8964XGA","arabic":"ص ق ا ٨٩٦٤","confidence":0.99,"decision":"open","matched":"8964XGA","reason":"resident resident-A unit 12"}
{"event":6,"read":"8964XGA","arabic":"ص ق ا ٨٩٦٤","confidence":1.0,"decision":"ignore","matched":"8964XGA","reason":"same car seen again within 15 s"}
{"event":7,"read":"6251EJA","arabic":"ع ح ا ٦٢٥١","confidence":1.0,"decision":"open","matched":"6251EJA","reason":"resident resident-B unit 7"}
{"event":8,"read":"8164ARA","arabic":"ا ر ا ٨١٦٤","confidence":0.91,"decision":"ask_guard","matched":"9164ARA","reason":"not on the list, one character away from 9164ARA"}
{"event":9,"read":"6878RRJ","arabic":"ر ر ح ٦٨٧٨","confidence":1.0,"decision":"deny","matched":null,"reason":"not on the list"}
{"event":10,"read":"666SBU","arabic":"س ب و ٦٦٦","confidence":0.91,"decision":"open","matched":"666SBU","reason":"visitor visitor-plumber unit 12"}
{"event":11,"read":"8654J","arabic":"","confidence":1.0,"decision":"deny","matched":null,"reason":"not on the list"}

في المخرج أعلاه لاحظ التالي:

  • الحدث 5 فتح للساكن الأول، والحدث 6 هو السيارة نفسها بعد ثانية من الخلف، فتجاهلها النظام بدل أن يرسل نبضة ثانية لل Relay.
  • الحدث 8 هو الساكن الثالث، ولكن النموذج قرأ 8164ARA بدل 9164ARA، فلم يفتح النظام ولم يرفض، وإنما عرضها على الحارس مع اسم اللوحة القريبة.
  • الحدث 9 رفض لأن اللوحة ليست في القائمة، والحدث 11 هو اللوحة الإماراتية J8654 وقد صارت 8654J لأن normalize تضع الأرقام أولاً، وهذا لا يضر هنا لأنها تخزن وتبحث بالطريقة نفسها، ولكنه يعني أن الكود مكتوب للوحات السعودية، فإذا كانت بوابتك تستقبل لوحات خليجية كثيرة فاكتب لكل بلد شكله.

انتهاء تصريح الزائر والفتح اليدوي

بعد أن تمر الدقيقة نرسل صورة سيارة الزائر مرة أخرى، ثم يفتح الحارس للساكن الثالث يدوياً بعد أن تأكد منه عبر الاتصال الداخلي، ثم نجرب فتحاً يدوياً بسبب قصير، ونقرأ سجل ال Relay:

curl -s -X POST $G/api/camera/gate-in -F image=@images/sa-night.jpg; echo
curl -s -X POST $G/api/override -H 'X-Guard-Token: change-me-guard-token' -H 'X-Guard-Name: noura' \
  -d plate='9164 ARA' -d reason='resident-C confirmed on intercom, camera read 8164ARA'; echo
curl -s -X POST $G/api/override -H 'X-Guard-Token: change-me-guard-token' -d reason='ok'; echo
docker logs lab-plate-relay 2>&1 | grep RELAY

والمخرج سوف يكون كما يلي:

{"event":12,"read":"666SBU","arabic":"س ب و ٦٦٦","confidence":0.91,"decision":"deny","matched":"666SBU","reason":"visitor pass expired at 2026-10-09T08:21:21+00:00"}
{"event":13,"decision":"override"}
{"detail":"write why the gate was opened"}
2026-10-09T08:20:22+00:00 RELAY contact closed for 800 ms -> gate controller START input
2026-10-09T08:20:23+00:00 RELAY contact closed for 800 ms -> gate controller START input
2026-10-09T08:20:26+00:00 RELAY contact closed for 800 ms -> gate controller START input
2026-10-09T08:21:31+00:00 RELAY contact closed for 800 ms -> gate controller START input

رفض النظام الزائر لأن تصريحه انتهى قبل ثوان، دون أن يحذفه أحد من القائمة، وفتح الحارس للساكن الثالث وبقي في السجل اسمه والسبب، ورفض الطلب الذي سببه ok. وفي سجل ال Relay أربع نبضات فقط: ثلاث للسيارات التي فتح لها النظام، وواحدة للفتح اليدوي.

نكسر الإعداد: ال Relay لا يرد

الحل البسيط في دالة pulse_relay هو أن تترك الخطأ يخرج منها إذا لم يرد ال Relay، وعندها يرجع الطلب بخطأ 500، والأسوأ أن الحدث لا يكتب في السجل لأن الكتابة تأتي بعد الفتح، فيقف الساكن أمام ذراع لا يرتفع، ولا يجد الحارس في الصفحة أي أثر لما حدث. لذلك ترجع الدالة في الكود أعلاه نص الخطأ، وتكتب الخدمة الحدث بالقرار relay_error. ولنجرب ذلك نوقف ال Relay ونرسل صورة ساكن مسجل، ثم نعيد تشغيله:

docker stop lab-plate-relay
curl -s -X POST $G/api/camera/gate-in -F image=@images/sa-plate-ksa.jpg; echo
docker start lab-plate-relay

والمخرج سوف يكون كما يلي:

lab-plate-relay
{"event":14,"read":"6251EJA","arabic":"ع ح ا ٦٢٥١","confidence":1.0,"decision":"relay_error","matched":"6251EJA","reason":"resident resident-B unit 7 | relay error: ConnectError"}
lab-plate-relay

ولاحظ أن الخدمة عرفت الساكن، ولكنها لم تقل إنها فتحت، وإنما كتبت أن ال Relay لا يرد، فيظهر الحدث في صفحة الحارس باللون الأحمر، ويعرف الحارس أن عليه أن يفتح بنفسه وأن يبلغ عن العطل. والقاعدة هنا: لا تكتب في السجل أن البوابة فتحت إلا إذا أكد ال Relay ذلك.

وإذا كبر المشروع إلى عدة بوابات وكاميرات، فقد تفصل القراءة عن القرار بطابور Queue بينهما كما شرحنا في مقال الطابور Queue للمبتدئين، ولكن انتبه إلى أن أمر الفتح نفسه يجب ألا ينتظر في طابور، فالرسالة التي تصل بعد دقيقة تفتح البوابة لسيارة أخرى.

صفحة الحارس

نضيف ساكناً رابعاً، ثم نرسل صورة سيارته التي أخطأ النموذج في قراءتها، حتى نرى الصفحة وفيها سيارة تنتظر قرار الحارس:

curl -s -X POST $G/api/vehicles -H "$H" -H 'X-Guard-Name: noura' -d plate='0528 LUX' -d owner=resident-D -d unit=4; echo
curl -s -X POST $G/api/camera/gate-in -F image=@images/sa-plate-pre2014.jpg; echo

والمخرج سوف يكون كما يلي:

{"plate":"0528LUX","arabic":"ل و ص ٠٥٢٨","valid_until":null}
{"event":16,"read":"0523LUX","arabic":"ل و ص ٠٥٢٣","confidence":1.0,"decision":"ask_guard","matched":"0528LUX","reason":"not on the list, one character away from 0528LUX"}

ثم نفتح الصفحة على http://127.0.0.1:18490/، والصورة التالية تبينها:

صفحة ويب عربية عنوانها بوابة السكن آخر الأحداث وفيها زر فتح يدوي مع سبب، وتحت العنوان شريط أصفر يقول إن السيارة 0523LUX تنتظر قرار الحارس لأنها تختلف بحرف واحد عن 0528LUX، ثم جدول فيه رقم الحدث والوقت والكاميرا واللوحة المقروءة واللوحة بالعربي والثقة والقرار والسبب ومن قام به، والقرارات ملونة: فتح بالأخضر ورفض وتعطل ال Relay بالأحمر وبانتظار الحارس بالبرتقالي
صفحة الحارس: آخر الأحداث، وسيارة تنتظر قراره في الأعلى

تنظيف المختبر

نحذف ال Containers وال Volume والشبكة باسم المشروع، ثم ال Image التي بنيناها:

cd gate && docker compose down -v && cd ..
docker rm -f lab-plate-alpr
docker image rm lab-plate-gate:1

ثم احذف المجلد plate-lab إذا لم تعد تحتاج الصور، ولاحظ أن النماذج كانت داخل ال Containers فقط، فحذفها معها.

لماذا لا تكفي اللوحة وحدها؟

هل البوابة التي تفتح باللوحة آمنة؟ لا، والتفصيل فيما يلي. اللوحة ليست سراً، فهي مكتوبة على السيارة ليقرأها كل من يمر، ونظامنا لا يتحقق من أن اللوحة حقيقية، وإنما يتحقق من أن الحروف التي قرأها في القائمة، وبالتالي:

  • اللوحة المطبوعة: ورقة مطبوعة عليها لوحة ساكن، أو صورة لوحة على شاشة هاتف أمام الكاميرا، قد تقرؤها الكاميرا كما تقرأ اللوحة الحقيقية، فالنموذج يرى حروفاً ولا يرى معدناً.
  • اللوحة المنسوخة Cloned Plate: سيارة أخرى بلوحة مزورة تحمل رقم سيارة مسجلة، ولا يوجد في الصورة ما يفرق بينهما.
  • الساكن الذي غادر: إذا لم تحذف سيارته من القائمة فما زالت تفتح، وهذه أكثر حالة ستحدث فعلاً، وعلاجها إداري قبل أن يكون تقنياً، مثل واجهة الحذف DELETE /api/vehicles/{plate} التي ذكرناها في المعمارية أعلاه، يستدعيها من يسجل خروج الساكن.

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

الخصوصية والأنظمة في السعودية

اللوحة مع الوقت والصورة تعني أن فلاناً دخل وخرج في ساعة معينة، وبعد شهر تعرف منها متى يخرج الساكن إلى عمله ومتى يعود، وهذه بيانات شخصية. ولا نقدم هنا رأياً قانونياً في مشروع بعينه، وإنما نذكر ما في النصوص الرسمية حتى تعرف ما الذي تسأل عنه قبل التركيب:

  • نظام استخدام كاميرات المراقبة الأمنية الصادر بالمرسوم الملكي (م/34) في 1444/3/7هـ (3 أكتوبر 2022) يذكر في مادته الثانية ضمن ما يطبق عليه «المباني السكنية بما في ذلك المجمعات والعمائر السكنية» وفق وثيقة الشروط واللائحة، ويستثني في تعريف الكاميرات «الكاميرات التي يضعها الأفراد داخل الوحدات والمجمعات السكنية الخاصة».
  • المادة الرابعة من النظام نفسه توجب الاحتفاظ بالتسجيلات «بحسب المدة المنصوص عليها في وثيقة الشروط»، وألا يعدل التسجيل إذا كان هناك بلاغ عن حادث حتى ينتهي التحقيق، ولم نجد وثيقة الشروط منشورة لنذكر المدة منها، لذلك اسأل عنها الجهة المختصة قبل أن تضبط مدة الحذف في نظامك.
  • المادة السادسة توجب وضع «لوحة أو لوحات ظاهرة» تبين أن المكان مجهز بالكاميرات، وتحظر نقل التسجيلات أو نشرها إلا بموافقة الوزارة أو رئاسة أمن الدولة أو بأمر قضائي أو بطلب جهة التحقيق، والمادة الخامسة تشترط موافقة الوزارة لبيع الكاميرات وتركيبها وتشغيلها وصيانتها.
  • نظام حماية البيانات الشخصية يشترط في المادة الحادية عشرة أن يكون محتوى البيانات «مقصوراً على الحد الأدنى اللازم لتحقيق الغرض من جمعها»، ويوجب في المادة الثانية عشرة سياسة خصوصية متاحة لأصحاب البيانات عند جمعها، وفي المادة الثامنة عشرة إتلاف البيانات بعد انتهاء الغرض من جمعها دون تأخير، إلا إذا وجد مسوغ نظامي يوجب الاحتفاظ بها مدة محددة.

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

دليلأنواع كاميرات المراقبة وكيف تختار بينهاما الفرق بين أنواع كاميرات المراقبة، وما الذي تحتاجه في الكاميرا حتى تسجلها على جهازك، وكم قرصاً تحتاج للتسجيلات.دليلFrigate: تسجيل كاميرات المراقبة على سيرفرك مع كشف الأجسامكيف تثبت Frigate على سيرفرك، وتسجل به كاميرات المراقبة محلياً، ويكشف لك الأشخاص والسيارات دون أن يخرج الفيديو من بيتك.دليلتقسيم الشبكة Network Segmentation بال VLAN وال VPCلماذا تصل كاميرا مخترقة إلى قاعدة بياناتك، وكيف تمنع ذلك بتقسيم الشبكة إلى مناطق لا يمر بينها إلا ما تسمح به.دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.دليلدورة حياة حساب الموظف JML من أول يوم إلى آخر يومكيف تجعل الموارد البشرية مصدر كل حساب، فيجد الموظف ما يحتاجه في يومه الأول وتغلق كل أبوابه في يوم مغادرته.دليلسيارات مسح الخرائط: ماذا تجمع وأي عتاد تحمل؟ما الذي تسجله السيارات التي تجوب الشوارع بالكاميرات والليزر، وكيف تتحول صورها وقياساتها إلى عناوين وخرائط على هاتفك.

تدريب عملي

  • صور لوحة سيارتك بهاتفك من ثلاث زوايا (مستقيمة، و30 درجة، و60 درجة) ومن مسافتين، وأضفها إلى truth.json وشغل recognize_rows.py، ولاحظ عند أي زاوية تبدأ الأخطاء.
  • غير MIN_CONF في compose.yaml إلى 0.95 وأعد إرسال صورة الليل، ثم اشرح لماذا تغير القرار.
  • أضف إلى الخدمة حذفاً تلقائياً لأحداث الزوار التي مضى عليها أكثر من 30 يوماً، واكتب في السجل حدثاً بعدد ما حذف.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • ابدأ من الحارس: قائمة بأسماء أصحاب السيارات، وتصاريح زوار تنتهي وحدها، وفتح يدوي بسبب مكتوب، وقرار للحارس عند الشك، وسجل لكل حدث، وكل ذلك يعمل دون الإنترنت.
  • في اللوحة السعودية يقابل أول حرف عربي (من اليمين) أول حرف لاتيني (من اليسار)، وليس الحرف الذي تحته، فاحفظ كل لوحة بمفتاح واحد مثل 8964XGA وحول إليه كل كتابة.
  • ضع الكاميرا بارتفاع نصف المسافة وبزاوية 30 درجة على الأكثر كما في دليل Axis، وثبت الغالق على قيمة قصيرة، وأضف إضاءة للعمل في الليل.
  • افتح البوابة بنبضة قصيرة من Relay على مدخل الزر في وحدة التحكم، واترك التوصيل لفني معتمد وحماية الحركة لوحدة التحكم نفسها.
  • نموذج القراءة العام الذي استخدمناه يعرف الحروف اللاتينية فقط، فقرأ اللوحات السعودية كاملة خطأ كلها، وبعد أن أعطيناه الصف اللاتيني فقط قرأ 7 من 10، وكانت الأخطاء الثلاثة حرفاً واحداً، وإحداها بثقة 1.00.
  • افتح وحدك عند المطابقة التامة والثقة العالية فقط، واجعل المطابقة التقريبية سؤالاً للحارس، ولا تكتب أن البوابة فتحت إلا إذا أكد ال Relay ذلك.
  • اللوحة ليست سراً، فالقراءة وحدها لا تمنع لوحة مطبوعة أو منسوخة، وإذا احتجت أماناً أعلى فاجمعها مع بطاقة RFID أو تأكيد الحارس.
  • ضع لوحة ظاهرة، واكتب سياسة خصوصية، واحفظ الحد الأدنى، واحذف ما انتهى غرضه، وارجع إلى نظام كاميرات المراقبة الأمنية ونظام حماية البيانات الشخصية ووثيقة الشروط قبل التركيب.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، والتجربة بمكتبة fast-alpr 0.4.0 (open-image-models 0.6.0 وfast-plate-ocr 1.1.0 وONNX Runtime 1.30.0) داخل python:3.13.16-slim، والخدمة بأداة FastAPI 0.143.0 وSQLite على Docker Engine 29.8.0، ومراجعة توثيق Frigate 0.18 ودليل Axis ونظامي كاميرات المراقبة الأمنية وحماية البيانات الشخصية في 9 أكتوبر 2026.
  • أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.