في كل مجمع سكني أو شركة لها بوابة سيارات، يتكرر المشهد نفسه عشرات المرات في اليوم: سيارة تقف أمام الذراع، والحارس يرفع رأسه من الشاشة، ويتعرف على السيارة أو يسأل صاحبها، ثم يضغط الزر. والفكرة التي تخطر على البال هي أن تقرأ كاميرا لوحة السيارة وتفتح البوابة وحدها لمن يحق له الدخول، وهذا ما يسمى قراءة اللوحات آلياً Automatic Number Plate Recognition، واختصاراً ANPR، ويسميه البعض ALPR. وفي مقال أنواع كاميرات المراقبة اخترنا الكاميرا، وفي مقال Frigate سجلنا بها وكشفنا السيارات، وفي تجربة قراءة رقم المبنى رأينا أن الاكتشاف Detection يجب أن يسبق القراءة OCR. وهذا المقال من مرحلة معالجة الصور والمستندات في خريطة الطريق يجمع ذلك كله في مشروع واحد كامل، وسوف نناقش فيه ما يلي:
- ما الذي يحتاجه الحارس فعلاً من النظام، قبل أي كود.
- مكونات البوابة: الكاميرا والسيرفر وال Relay ووحدة التحكم في البوابة، وكيف تتصل ببعضها، ثم المعمارية الكاملة للموقع مع الأدوات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وما يحدث عندما يتعطل كل جزء.
- شكل اللوحة السعودية بحروفها العربية واللاتينية، والخطأ الذي يقع فيه أغلب من يكتب كود المقابلة بينها.
- أين تضع الكاميرا وبأي زاوية، وكيف تفتح البوابة من الكود دون أن تلمس كهرباء البوابة.
- الأدوات المفتوحة المصدر لاكتشاف اللوحة وقراءتها، ومنها ميزة قراءة اللوحات في Frigate.
- تجربة عملية كاملة: نقرأ 12 صورة لوحة حقيقية ونحسب الدقة بأمانة، ثم نبني خدمة فيها قائمة السيارات وتصاريح الزوار وصفحة للحارس وسجل لكل حدث.
- لماذا لا تكفي اللوحة وحدها للأمان، وما يقوله النظام في السعودية عن الكاميرات والبيانات الشخصية.
ماذا يحتاج الحارس من النظام؟
قد يتساءل البعض: لماذا نبدأ من الحارس وليس من النموذج الذي يقرأ اللوحة؟ والإجابة أن قراءة اللوحة هي الجزء السهل نسبياً، أما ما يجعل النظام يعمل كل يوم فهو ما يحدث حول القراءة، وإذا جلست مع حارس بوابة نصف ساعة سوف تخرج بقائمة تشبه القائمة التالية:
- قائمة السيارات المصرح لها Allowlist: لوحة كل ساكن أو موظف، ومعها اسمه ورقم الوحدة أو القسم، ومن أضافها ومتى، لأن الحارس يحتاج أن يعرف لمن السيارة وليس رقمها فقط.
- تصريح الزائر المؤقت Visitor Pass: السباك أو الضيف أو سيارة التوصيل يدخل اليوم فقط، أو لساعتين، ثم ينتهي تصريحه وحده دون أن يتذكر أحد حذفه، والسبب أن القوائم التي يضاف إليها ولا يحذف منها تكبر حتى يصبح نصفها سيارات لا يعرفها أحد.
- الفتح اليدوي مع سبب Manual Override: الكاميرا ستخطئ، والكهرباء ستنقطع، وسيارة الإسعاف لن تنتظر، لذلك يجب أن يستطيع الحارس الفتح بزر، ولكن مع سبب مكتوب واسمه، حتى لا يصبح الزر طريقة دائمة لتجاوز النظام.
- قرار الحارس عند الشك: عندما يقرأ النظام لوحة قريبة من لوحة مسجلة، أو يقرأ بثقة منخفضة، فالأفضل أن يعرض الحالة على الحارس بدل أن يفتح أو يرفض وحده.
- سجل الأحداث Event Log: كل قراءة وكل قرار وكل فتح يدوي في سطر واحد فيه الوقت والكاميرا واللوحة والقرار والسبب ومن فعله، وهذا السجل هو ما ترجع إليه عندما يسألك أحد: من دخل ليلة الخميس؟
- العمل دون الإنترنت: البوابة يجب أن تفتح لسكانها إذا انقطع الإنترنت، لذلك يعمل كل شيء على سيرفر داخل المكان.
ولاحظ أن نصف هذه المتطلبات لا علاقة له بالذكاء الاصطناعي، وإنما هي إدارة صلاحيات عادية، وهي نفس فكرة دورة حياة حساب الموظف JML: الساكن الذي يغادر المجمع يجب أن تحذف سيارته في اليوم نفسه، كما يغلق حساب الموظف الذي يغادر الشركة.
المعمارية الكاملة: مكونات البوابة وكيف تتصل ببعضها
الصورة التالية تبين المكونات الستة للمشروع، والأرقام فيها تقابل القائمة تحتها:

- الكاميرا: كاميرا شبكية IP Camera موجهة إلى مكان وقوف السيارة أمام الذراع، ترسل بثها عبر RTSP كما شرحنا في قسم RTSP وONVIF.
- سيرفر البوابة: يأخذ الإطار من البث، ويجد مكان اللوحة بنموذج اكتشاف Detector، ويقرأ حروفها بنموذج OCR، ثم يطبق القواعد: هل اللوحة في القائمة؟ هل التصريح ساري؟ هل الثقة كافية؟
- ال Relay: جهاز صغير على الشبكة فيه مفتاح كهربائي، يطلب منه السيرفر عبر HTTP أن يغلق تلامسه لحظة قصيرة ثم يفتحه.
- وحدة التحكم في البوابة Gate Controller: اللوحة الإلكترونية التي تأتي مع البوابة نفسها، وفيها مدخل للزر أو جهاز التحكم عن بعد، وتلامس ال Relay يبدو لها كأن أحداً ضغط الزر.
- قاعدة البيانات: قائمة السيارات وتصاريح الزوار وجدول الأحداث، وفي التجربة نستخدم SQLite لأنها ملف واحد يكفي بوابة أو بوابتين.
- صفحة الحارس: صفحة ويب على شاشة في غرفة الحراسة تعرض آخر الأحداث والسيارات التي تنتظر قراره، وفيها زر الفتح اليدوي.
وقد يتساءل البعض: لماذا لا ترسل الكاميرا الصور إلى خدمة سحابية تقرأ اللوحات؟ والإجابة أن البوابة يجب أن تفتح إذا انقطع الإنترنت، وأن صور السيارات ولوحاتها بيانات عن الناس وتحركاتهم كما سيأتي في قسم الأنظمة، فالأنسب أن تبقى داخل المكان. ولاحظ أيضاً أن الكاميرا وال Relay ووحدة التحكم أجهزة يسهل اختراقها إذا كانت على نفس شبكة الضيوف، لذلك ضعها في شبكة منفصلة لا يصل إليها إلا سيرفر البوابة، كما شرحنا في مقال تقسيم الشبكة Network Segmentation.
البوابة كاملة في الموقع
الصورة السابقة تبين بوابة واحدة، أما في مجمع حقيقي فهناك غالباً مدخل ومخرج، وحارس وإدارة تضيف السكان، ونسخ احتياطية، وكل ذلك موزع على شبكات منفصلة. والصورة التالية تبين النظام كاملاً، والخط المتصل فيها طلب ينتظر رداً Sync، والخط المتقطع شيء يحدث في الخلفية Async:

المكونات والأدوات المقترحة
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| اكتشاف اللوحة وقراءتها | صندوق اللوحة ثم حروف الصف اللاتيني | 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_error | Uptime 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 | جهاز صغير في كل موقع يقرر وحده ولو انقطع عن المركز، والمركز لا يفتح أي بوابة |
| البيانات | SQLite | PostgreSQL ونسخ احتياطي كل ليلة | قائمة كل موقع عنده محلياً، تزامن من المركز، والأحداث ترسل إلى المركز لاحقاً |
| الحارس والإدارة | رمز حارس واحد كما في التجربة | تسجيل دخول لكل حارس عبر authentik، وصفحة للإدارة | صلاحيات لكل موقع، وتقارير مركزية، وتحديث النماذج على كل الأجهزة بالتدريج |
| الأمان | شبكة منفصلة للكاميرا وال Relay | تنبيه عند تعطل الأجهزة وعند تكرار الفتح اليدوي | عامل ثان مثل بطاقة RFID للأماكن الحساسة |
ولا تقم في اليوم الأول بشراء كرت شاشة أو بناء مركز لعدة مواقع، والسبب أن بوابة واحدة تمر بها سيارة كل سبع ثوان في أسوأ ساعة، وما تحتاجه من اليوم الأول هو ما لا يتغير مع الحجم: القرار يحدث داخل الموقع، والمطابقة التقريبية سؤال للحارس، وكل حدث يسجل، والكاميرا وال Relay في شبكة لا يصل إليها غير السيرفر.
ماذا لو تعطل جزء؟
| ماذا لو تعطل…؟ | الأثر | الحل |
|---|---|---|
| الكاميرا، أو اتسخت عدستها، أو جاء الليل | لا توجد قراءات، أو قراءات بثقة منخفضة | الحالة تذهب إلى الحارس بدل أن يفتح النظام أو يرفض وحده، والتنبيه يخبرك أن الكاميرا لا ترد، والإضاءة والغالق كما في قسم الكاميرا |
| سيرفر البوابة | لا قراءة ولا قرار | وحدة التحكم في البوابة تعمل كما كانت قبل المشروع بالزر وجهاز التحكم عن بعد، فالحارس يفتح بنفسه حتى يعود السيرفر |
| ال Relay | السيرفر يقرر الفتح ولا ترتفع الذراع | القرار يكتب relay_error وليس open، ويظهر بالأحمر عند الحارس، كما رأيت عندما أوقفناه في التجربة |
| قاعدة البيانات أو القرص | لا يعرف النظام السكان | نسخة restic كل ليلة واستعادة مجربة، وفتح يدوي حتى تنتهي الاستعادة |
| الكهرباء | كل شيء يتوقف | UPS للسيرفر والسويتش وال Relay، وتأكد من سلوك وحدة التحكم في البوابة عند انقطاع الكهرباء من دليلها |
| الإنترنت | لا تحديثات ولا نسخ خارج الموقع ولا تنبيهات خارجية | لا شيء في فتح البوابة يحتاجه، وهذا هو سبب أن كل شيء داخل الموقع |
كيف تقرأ اللوحة السعودية؟
اللوحة السعودية الخاصة فيها صفان: الصف العلوي بالحروف العربية والأرقام الهندية (٠ ١ ٢ ٣…)، والصف السفلي بالحروف اللاتينية والأرقام العربية الغربية (0 1 2 3…)، وفي كل صف جزء للأرقام وجزء لثلاثة حروف، وبجانبهما شريط فيه كلمة السعودية وKSA. والصورة التالية للوحة حقيقية من الطراز الذي يركب على السيارات اليوم:

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

والحروف المستخدمة في اللوحات سبعة عشر حرفاً فقط، لكل منها حرف لاتيني ثابت، كما في الجدول التالي:
| العربي | اللاتيني | العربي | اللاتيني | العربي | اللاتيني |
|---|---|---|---|---|---|
| ا | 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 يعمل داخل الكاميرا ويقرأ اللوحات لنفس الغرض، والصورة التالية تجمعها:

- الارتفاع والمسافة: يذكر الدليل أن ارتفاع الكاميرا في حالة التحكم في الدخول 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 يقول صراحة إن القراءة ليلاً تعتمد على جودة الكاميرا والإضاءة وقدرتها بالأشعة تحت الحمراء، وإن عليك أن تتأكد أن الكاميرا تلتقط صورة واضحة للوحة في الليل. والصورة التالية لسيارة سعودية ليلاً في شارع مضاء، وقد قرأها النموذج في تجربتنا كما سيأتي، ولكن لاحظ الإضاءة القوية حولها وأن السيارة واقفة:

ولاحظ في الصورة أعلاه أن الحافلة التي مرت خلف السيارة تحولت إلى خطوط ضوء، لأن المصور ترك الغالق مفتوحاً مدة طويلة، ولو كانت السيارة هي التي تتحرك لصارت لوحتها خطوطاً بالطريقة نفسها، وهذا بالضبط ما يحدث لكاميرا بوابة تركت إعداداتها على الوضع التلقائي في الليل.
كيف يفتح الكود البوابة؟
أغلب وحدات التحكم في البوابات المنزلقة والأذرع فيها مدخل للزر، أو للضاغط الذي يمسكه الحارس، وهذا المدخل لا يحتاج كهرباء من الخارج، وإنما يحتاج تلامساً جافاً Dry Contact، أي مفتاحاً يوصل طرفين ببعضهما لحظة قصيرة ثم يفصلهما، تماماً كما يفعل الزر عندما تضغطه. لذلك فالطريقة الآمنة هي أن تضع Relay على الشبكة يقلد ضغطة الزر، ومنه خياران شائعان:
- Relay شبكي جاهز: مثل Shelly 1 Gen3 الذي تصفه الشركة بأنه مفتاح ذكي بتلامس معزول Potential-free، ويعمل على شبكة Wi-Fi المحلية دون السحابة، وواجهته البرمجية فيها الأمر
Switch.Setمع الخيارtoggle_afterالذي يعيد المفتاح إلى حالته بعد عدد من الثواني، فتحصل على نبضة واحدة بطلب واحد. - لوحة ESP32 مع Relay: إذا كنت تحب البناء بنفسك، وتوثيق ESPHome فيه مثال لهذه الحالة بالضبط: Relay يقلد ضغط زر جهاز التحكم في البوابة لمدة 500 ملي ثانية ثم يعود، ويذكر التوثيق أن الزر يشغل محرك البوابة أو يوقفه فقط، ولا يعرف إن كانت ستفتح أو تغلق.
وفي التجربة سوف نستبدل ال Relay الحقيقي بخدمة صغيرة تستقبل الطلب نفسه وتكتب في سجلها أنها أغلقت التلامس 800 ملي ثانية، حتى يعمل المشروع كاملاً دون عتاد.
ولاحظ أن ال 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 أو تقرأ بنفسها، ثم تقرر.
كيف يقرر السيرفر فتح البوابة؟
الحل البسيط هو: إذا كانت اللوحة المقروءة في القائمة فافتح، وإلا فارفض، ولكن هذا الحل يفشل في ثلاث حالات سوف تراها في التجربة: النموذج يقرأ اللوحة بحرف خطأ فيرفض ساكناً، والكاميرا ترى السيارة نفسها في عشرين إطاراً فترسل عشرين أمراً بالفتح، وتصريح الزائر الذي انتهى أمس ما زال في القائمة. لذلك يمر كل قرار بالخطوات التي تبينها الصورة التالية:

- نافذة التكرار: إذا فتحنا لهذه اللوحة قبل أقل من 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% من ارتفاعه إلى أسفله) ونعطيه لنموذج القراءة، والصورة التالية تبين صندوق الاكتشاف وخط القص على إحدى صور التجربة:

والسكربت 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والجدول التالي يجمع النتائج الثلاث على الصور الاثنتي عشرة:
| الإعداد | صحيحة | خطأ | لم يجد لوحة |
|---|---|---|---|
| النموذجان الصغيران، اللوحة كاملة | 1 | 9 | 2 |
| النموذجان الأكبر، اللوحة كاملة | 2 | 10 | 0 |
| النموذجان الأكبر، الصف اللاتيني للوحات السعودية | 9 | 3 | 0 |
في النتيجة الأخيرة لاحظ التالي:
- سبع لوحات سعودية من عشر صحيحة، ومنها صورة الليل وصورة السيارة في الزحام، بعد أن كانت صفراً من عشر.
- الأخطاء الثلاثة كلها حرف واحد: 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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/، والصورة التالية تبينها:

تنظيف المختبر
نحذف ال 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: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.