مشروع تطبيقي: قراءة لوحات السيارات وعدّ المركبات
قراءة لوحات المركبات وعدّها من أكثر تطبيقات الذكاء الاصطناعي شيوعًا في كاميرات المراقبة. يجب على النظام أن يجد المركبات واللوحات في كل إطار، وأن يربط كل لوحة بالمركبة الصحيحة، وأن يقرأ الحروف، وأن يعدّ كل مركبة مرة واحدة فقط. تشرح هذه المقالة المنطق بكود Node.js يعمل فعليًا، مستخدمةً بيانات محاكاة بدل نموذج الكشف، حتى يتضح المنطق. وتنطبق الخطوات نفسها على النظام الحقيقي.
ما الذي تحتاجه
تحتاج إلى Node.js بالإصدار 18 أو أحدث، وأساسيات JavaScript. شغّل كل ملف بالأمر node اسم_الملف.js، ولا تحتاج إلى حزم إضافية. تشغيل نموذج كشف وOCR حقيقي يحتاج إلى Python وultralytics، ولم يُشغَّل ذلك في جهاز الاختبار لهذه المقالة، لذلك ورد في المثال الأخير كخطوات فقط. ملاحظة: قاعدة اللوحات هنا بسيطة للتوضيح، ويجب تعديلها وفق صيغ اللوحات المستخدمة في منطقتك، وتركيب الكاميرات يجب أن يلتزم بقوانين الخصوصية في البلد الذي يُركَّب فيه.
رسوم توضيحية
1. نظرة عامة على النظام
يتكون النظام من ست خطوات: استقبال الإطارات من الكاميرا، وكشف المركبات واللوحات، وتتبع كل مركبة عبر الإطارات، ومطابقة اللوحة بالمركبة، وقراءة النص وتنظيفه، ثم العدّ وحفظ النتائج.
1. استقبال الإطارات من الكاميرا واحدًا تلو الآخر
2. كشف مربعات المركبات ومربعات اللوحات في كل إطار
3. تتبع المركبة لتحمل المعرّف نفسه عبر الإطارات
4. مطابقة كل لوحة بالمركبة التي يحتوي مربعها على اللوحة
5. قراءة نص اللوحة وتصحيح الحروف المتشابهة والتصويت عبر الإطارات
6. عدّ المركبات التي تعبر خطًا افتراضيًا، ثم حفظ النتائج في ملف
يعرض المخطط أعلاه هذه الخطوات كلها.
2. مطابقة اللوحة بالمركبة
يجب أن تقع اللوحة داخل مربع مركبتها، لذلك نطابق كل لوحة بالتحقق من المركبة التي يحتوي مربعها على مربع اللوحة. إذا لم يحتوِ أي مربع عليها، فيُبلَّغ بأنه لم يُعثر على مركبة مطابقة.
**الكود: ex01_plate_in_car.js**
// จับคู่ป้ายทะเบียนกับรถ: ป้ายที่อยู่ในกรอบของรถคันใด ให้ถือว่าเป็นของรถคันนั้น
function contains(outer, inner) {
return inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
}
const cars = [
{ id: "รถ A", box: [100, 200, 400, 380] },
{ id: "รถ B", box: [500, 210, 780, 390] },
];
const plates = [
{ text: "กข 1234", box: [200, 330, 300, 360] },
{ text: "ขค 5678", box: [600, 340, 690, 370] },
{ text: "งจ 9999", box: [850, 300, 900, 330] },
];
for (const p of plates) {
const owner = cars.find(c => contains(c.box, p.box));
console.log(p.text.padEnd(9), owner ? "อยู่ในกรอบ " + owner.id : "ไม่พบรถที่ตรงกัน");
}
**المخرجات الفعلية**
กข 1234 อยู่ในกรอบ รถ A
ขค 5678 อยู่ในกรอบ รถ B
งจ 9999 ไม่พบรถที่ตรงกัน
3. عدّ المركبات بخط افتراضي
نرسم خطًا عموديًا في الصورة ونعدّ المركبة عندما تنتقل نقطة مركزها إلى الجهة الأخرى من الخط. تغيّر إشارة الفرق يدل على عبور الخط.
**الكود: ex02_line_crossing.js**
// นับรถด้วยเส้นเสมือน: นับเมื่อจุดกลางของรถเคลื่อนข้ามเส้นจากฝั่งหนึ่งไปอีกฝั่ง
const LINE_X = 400;
// ตำแหน่ง x ของจุดกลางรถในแต่ละเฟรม
const cars = {
"รถ A": [300, 340, 380, 420, 460],
"รถ B": [100, 120, 140, 160],
"รถ C": [500, 450, 390, 350],
};
let total = 0;
for (const [name, xs] of Object.entries(cars)) {
for (let i = 1; i < xs.length; i++) {
const before = xs[i - 1] - LINE_X;
const after = xs[i] - LINE_X;
// เครื่องหมายต่างกันแปลว่าข้ามเส้น
if (before * after < 0) {
total++;
const dir = after > 0 ? "ไปทางขวา" : "ไปทางซ้าย";
console.log(`${name} ข้ามเส้นที่เฟรม ${i} ${dir}`);
}
}
}
console.log("รวมทั้งหมด", total, "คัน");
**المخرجات الفعلية**
รถ A ข้ามเส้นที่เฟรม 3 ไปทางขวา
รถ C ข้ามเส้นที่เฟรม 2 ไปทางซ้าย
รวมทั้งหมด 2 คัน
4. تتبع المركبات عبر الإطارات باستخدام IoU
في كل إطار نقارن كل كشف بالمربعات المتتبَّعة باستخدام IoU. إذا كان التداخل كافيًا (أكبر من 0.3) نُبقي المعرّف الموجود، وإلا ننشئ معرّفًا جديدًا.
**الكود: ex03_iou_tracker.js**
// ติดตามรถข้ามเฟรมด้วย IoU: กรอบที่ทับกับของเดิมมากพอให้ใช้ ID เดิม ถ้าไม่มีให้สร้าง ID ใหม่
function iou(a, b) {
const ix1 = Math.max(a[0], b[0]), iy1 = Math.max(a[1], b[1]);
const ix2 = Math.min(a[2], b[2]), iy2 = Math.min(a[3], b[3]);
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
}
const frames = [
[[100, 200, 220, 300], [500, 210, 620, 310]],
[[140, 200, 260, 300], [470, 210, 590, 310]],
[[180, 200, 300, 300], [440, 210, 560, 310]],
[[220, 200, 340, 300]],
];
const tracks = [];
let nextId = 1;
frames.forEach((dets, f) => {
const used = new Set();
const ids = [];
for (const box of dets) {
let best = null, bestIou = 0.3;
for (const t of tracks) {
if (used.has(t.id)) continue;
const v = iou(t.box, box);
if (v >= bestIou) { bestIou = v; best = t; }
}
if (!best) {
best = { id: nextId++, box };
tracks.push(best);
}
best.box = box;
used.add(best.id);
ids.push(best.id);
}
console.log(`เฟรม ${f}: ID ${ids.join(", ")}`);
});
console.log("จำนวน ID ทั้งหมด:", nextId - 1);
**المخرجات الفعلية**
เฟรม 0: ID 1, 2
เฟรม 1: ID 1, 2
เฟรม 2: ID 1, 2
เฟรม 3: ID 1
จำนวน ID ทั้งหมด: 2
5. عدّ كل مركبة مرة واحدة فقط
نجمع التتبع مع الخط الافتراضي، ونتذكر أي معرّف عبر الخط من قبل. نعدّ المعرّف عند أول عبور له فقط. لو عددنا كل إطار يحدث فيه العبور لعُدّت المركبة الواحدة أكثر من مرة.
**الكود: ex04_count_unique.js**
// นับรถที่ไม่ซ้ำกัน: ติดตามรถด้วย ID แล้วนับเฉพาะครั้งแรกที่แต่ละ ID ข้ามเส้น
// ถ้านับทุกเฟรมที่ข้ามเส้น รถคันเดียวจะถูกนับซ้ำ
const LINE_X = 400;
function iou(a, b) {
const ix1 = Math.max(a[0], b[0]), iy1 = Math.max(a[1], b[1]);
const ix2 = Math.min(a[2], b[2]), iy2 = Math.min(a[3], b[3]);
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
}
// รถ A เคลื่อนไปทางขวา รถ B เคลื่อนไปทางซ้าย
const frames = [];
for (let f = 0; f < 8; f++) {
const xa = 60 + 50 * f;
const xb = 620 - 50 * f;
frames.push([[xa, 200, xa + 120, 300], [xb, 340, xb + 120, 440]]);
}
const tracks = [];
let nextId = 1;
let counted = 0;
frames.forEach((dets, f) => {
const used = new Set();
for (const box of dets) {
let best = null, bestIou = 0.3;
for (const t of tracks) {
if (used.has(t.id)) continue;
const v = iou(t.box, box);
if (v >= bestIou) { bestIou = v; best = t; }
}
if (!best) {
best = { id: nextId++, box, lastX: null, crossed: false };
tracks.push(best);
}
best.box = box;
used.add(best.id);
const cx = (box[0] + box[2]) / 2;
if (best.lastX !== null && !best.crossed && (best.lastX - LINE_X) * (cx - LINE_X) < 0) {
best.crossed = true;
counted++;
console.log(`เฟรม ${f}: ID ${best.id} ข้ามเส้น นับเป็นคันที่ ${counted}`);
}
best.lastX = cx;
}
});
console.log("รถที่นับได้ทั้งหมด:", counted, "คัน");
**المخرجات الفعلية**
เฟรม 6: ID 1 ข้ามเส้น นับเป็นคันที่ 1
เฟรม 6: ID 2 ข้ามเส้น นับเป็นคันที่ 2
รถที่นับได้ทั้งหมด: 2 คัน
6. تنظيف النص القادم من OCR
غالبًا ما ينتج OCR مسافات زائدة ويخلط بين الحروف، مثل O مع 0، وl مع 1، وB مع 8. تزيل هذه الخطوة المسافات الزائدة، وتفصل الحروف التايلاندية عن الأرقام، ثم تصحح الجزء الرقمي فقط.
**الكود: ex05_clean_text.js**
// OCR มักอ่านผิด: มีช่องว่างเกิน และสับสนตัวอักษรกับตัวเลข เช่น O กับ 0, l กับ 1
// ขั้นตอนนี้ตัดช่องว่าง แยกส่วนตัวอักษรไทยกับตัวเลข แล้วแก้ตัวที่สับสนเฉพาะส่วนตัวเลข
function cleanText(raw) {
const s = raw.replace(/\s+/g, "");
const m = s.match(/^([ก-ฮ]+)(.*)$/);
if (!m) return raw;
const digits = m[2]
.replace(/O/g, "0")
.replace(/[lI]/g, "1")
.replace(/B/g, "8")
.replace(/S/g, "5");
return m[1] + " " + digits;
}
const readings = ["กข 1234", "ก ข 1 2 3 4", "กข 1l34", "กข 12O4", "กข 1B34"];
for (const r of readings) {
console.log(JSON.stringify(r).padEnd(16), "->", cleanText(r));
}
**المخرجات الفعلية**
"กข 1234" -> กข 1234
"ก ข 1 2 3 4" -> กข 1234
"กข 1l34" -> กข 1134
"กข 12O4" -> กข 1204
"กข 1B34" -> กข 1834
7. التحقق من صيغة اللوحة قبل قبولها
نستخدم التعبير النمطي للتأكد من أن النص يطابق الصيغة المتوقعة، فيُستبعد ما هو خاطئ بوضوح.
**الكود: ex06_validate.js**
// ตรวจรูปแบบป้ายก่อนยอมรับผล: ตัวอย่างนี้ใช้รูปแบบ อักษรไทย 1-3 ตัว เว้นวรรด หนึ่งช่อง แล้วตามด้วยตัวเลข 1-4 หลัก
// ตัวอย่างนี้เป็นกติกาอย่างง่ายเพื่อสาธิต ควรปรับตามรูปแบบป้ายที่ใช้จริงในพื้นที่
const PATTERN = /^[ก-ฮ]{1,3} \d{1,4}$/;
const samples = ["กข 1234", "กข1234", "กขค 56", "12 3456", "กข 12345", "งจ 9999"];
for (const s of samples) {
console.log(s.padEnd(10), PATTERN.test(s) ? "ผ่าน" : "ไม่ผ่าน");
}
**المخرجات الفعلية**
กข 1234 ผ่าน
กข1234 ไม่ผ่าน
กขค 56 ผ่าน
12 3456 ไม่ผ่าน
กข 12345 ไม่ผ่าน
งจ 9999 ผ่าน
8. التصويت على القراءات من عدة إطارات
تُقرأ المركبة الواحدة في إطارات كثيرة، وقد تختلف كل قراءة قليلًا. نحذف القراءات غير المطابقة للصيغة، ثم نختار القراءة الأكثر تكرارًا.
**الكود: ex07_vote.js**
// อ่านป้ายจากหลายเฟรมแล้วเลือกผลที่ปรากฏบ่อยที่สุด (majority vote)
// อ่านผิดเฟรมเดียวจะไม่ทำให้ผลสุดท้ายผิด และตัดผลที่ไม่ผ่านรูปแบบออกก่อน
function isValid(t) {
return /^[ก-ฮ]{1,3} \d{1,4}$/.test(t);
}
function vote(readings) {
const count = {};
for (const r of readings.filter(isValid)) count[r] = (count[r] || 0) + 1;
const sorted = Object.entries(count).sort((a, b) => b[1] - a[1]);
return sorted.length ? sorted[0][0] : "ไม่พบ";
}
const trackA = ["กข 1234", "กข 1234", "กบ 1234", "กข 1Z34", "กข 1234"];
const trackB = ["ขค 5678", "ขค 5678", "ขค 5678"];
const trackC = ["xyz", "กข 12"];
console.log("รถ A:", vote(trackA));
console.log("รถ B:", vote(trackB));
console.log("รถ C:", vote(trackC));
**المخرجات الفعلية**
รถ A: กข 1234
รถ B: ขค 5678
รถ C: กข 12
9. حفظ النتائج بصيغة CSV
نضع القيم التي تحتوي على فواصل بين علامتي اقتباس حتى لا تنزاح الأعمدة.
**الكود: ex08_csv.js**
// บันทึกผลเป็นไฟล์ CSV เพื่อเปิดในโปรแกรมตารางคำนวณได้ทันที
// ต้องใส่เครื่องหมายคำพูดครอบค่าที่มีเครื่องหมายคอมมาหรือขึ้นบรรทัดใหม่
function toCsvLine(values) {
return values
.map(v => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
})
.join(",");
}
const header = ["id", "ป้าย", "เฟรมที่เห็นครั้งแรก", "ทิศทาง", "หมายเหตุ"];
const rows = [
[1, "กข 1234", 0, "ไปทางขวา", "ข้ามเส้นที่เฟรม 6"],
[2, "ขค 5678", 0, "ไปทางซ้าย", "ข้ามเส้น, ป้ายอ่านผิด 1 เฟรม"],
[3, "งจ 9999", 0, "-", "จอดอยู่, ไม่ข้ามเส้น"],
];
console.log(toCsvLine(header));
for (const r of rows) console.log(toCsvLine(r));
**المخرجات الفعلية**
id,ป้าย,เฟรมที่เห็นครั้งแรก,ทิศทาง,หมายเหตุ
1,กข 1234,0,ไปทางขวา,ข้ามเส้นที่เฟรม 6
2,ขค 5678,0,ไปทางซ้าย,"ข้ามเส้น, ป้ายอ่านผิด 1 เฟรม"
3,งจ 9999,0,-,"จอดอยู่, ไม่ข้ามเส้น"
10. تلخيص عدد المركبات لكل ساعة
نأخذ وقت كل حدث تم عدّه، ثم نجمع الأحداث حسب الساعة، لنعرف الساعات الأكثر ازدحامًا.
**الكود: ex09_hourly.js**
// สรุปจำนวนรถรายชั่วโมง: แยกเวลาของแต่ละเหตุการณ์ที่นับได้ แล้วรวมตามชั่วโมง
const events = [
"08:05", "08:12", "08:47", "09:03", "09:10", "09:58", "10:02", "10:15", "10:16",
];
const perHour = {};
for (const t of events) {
const hour = t.slice(0, 2);
perHour[hour] = (perHour[hour] || 0) + 1;
}
for (const [hour, n] of Object.entries(perHour).sort()) {
console.log(`${hour}:00 - ${hour}:59 ${"#".repeat(n)} ${n} คัน`);
}
console.log("รวมทั้งหมด", events.length, "คัน");
**المخرجات الفعلية**
08:00 - 08:59 ### 3 คัน
09:00 - 09:59 ### 3 คัน
10:00 - 10:59 ### 3 คัน
รวมทั้งหมด 9 คัน
11. تجميع النظام كله
يستخدم هذا المثال بيانات محاكاة لثمانية إطارات. تسير المركبتان A وB في مساريهما، وتبقى المركبة C متوقفة. وينفذ المثال كل الخطوات: التتبع والمطابقة والتنظيف والتصويت والعدّ.
**الكود: ex10_full_pipeline.js**
// ตัวอย่างรวม: ข้อมูลจำลองแต่ละเฟรม -> ติดตามรถ -> จับคู่ป้าย -> แก้ข้อความป้าย -> โหวตผล -> นับรถที่ข้ามเส้น
// ในระบบจริงข้อมูลแต่ละเฟรมจะมาจากโมเดลตรวจจับและ OCR แทนที่จะสร้างขึ้นเอง
const LINE_X = 400;
function iou(a, b) {
const ix1 = Math.max(a[0], b[0]), iy1 = Math.max(a[1], b[1]);
const ix2 = Math.min(a[2], b[2]), iy2 = Math.min(a[3], b[3]);
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter);
}
function contains(outer, inner) {
return inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
}
function cleanText(raw) {
const s = raw.replace(/\s+/g, "");
const m = s.match(/^([ก-ฮ]+)(.*)$/);
if (!m) return raw;
return m[1] + " " + m[2].replace(/O/g, "0").replace(/[lI]/g, "1").replace(/B/g, "8").replace(/S/g, "5");
}
function isValid(t) {
return /^[ก-ฮ]{1,3} \d{1,4}$/.test(t);
}
function vote(readings) {
const count = {};
for (const r of readings.filter(isValid)) count[r] = (count[r] || 0) + 1;
const sorted = Object.entries(count).sort((a, b) => b[1] - a[1]);
return sorted.length ? sorted[0][0] : "ไม่พบ";
}
// ข้อมูลจำลอง 8 เฟรม: รถ A ไปทางขวา รถ B ไปทางซ้าย รถ C จอดอยู่
const frames = [];
for (let f = 0; f < 8; f++) {
const xa = 60 + 50 * f;
const xb = 620 - 50 * f;
frames.push({
cars: [[xa, 200, xa + 120, 300], [xb, 340, xb + 120, 440], [250, 60, 370, 160]],
plates: [
{ box: [xa + 30, 270, xa + 90, 290], text: ["กข 1234", "กข 1Z34", "กข 1234", "กข 1234", "กบ 1234", "กข 1234", "กข 1234", "กข 1234"][f] },
{ box: [xb + 30, 390, xb + 90, 410], text: ["ขค 5678", "ขค 567B", "ขค 5678", "ขค 5678", "ขค 5678", "ขค 5678", "ขค 5678", "ขค 5678"][f] },
{ box: [280, 110, 340, 130], text: "งจ 9999" },
],
});
}
const tracks = [];
let nextId = 1;
for (const fr of frames) {
const used = new Set();
const carTracks = fr.cars.map(box => {
let best = null, bestIou = 0.3;
for (const t of tracks) {
if (used.has(t.id)) continue;
const v = iou(t.box, box);
if (v >= bestIou) { bestIou = v; best = t; }
}
if (!best) {
best = { id: nextId++, box, lastX: null, crossed: false, dir: "-", plates: [] };
tracks.push(best);
}
best.box = box;
used.add(best.id);
return best;
});
fr.cars.forEach((box, i) => {
const t = carTracks[i];
const cx = (box[0] + box[2]) / 2;
if (t.lastX !== null && !t.crossed && (t.lastX - LINE_X) * (cx - LINE_X) < 0) {
t.crossed = true;
t.dir = cx > LINE_X ? "ไปทางขวา" : "ไปทางซ้าย";
}
t.lastX = cx;
});
for (const p of fr.plates) {
fr.cars.forEach((box, i) => {
if (contains(box, p.box)) carTracks[i].plates.push(cleanText(p.text));
});
}
}
console.log("ID | ป้าย | ข้ามเส้น | ทิศทาง");
for (const t of tracks) {
console.log(`${t.id} | ${vote(t.plates)} | ${t.crossed ? "ใช่" : "ไม่"} | ${t.dir}`);
}
console.log("รถที่ข้ามเส้นทั้งหมด:", tracks.filter(t => t.crossed).length, "คัน");
**المخرجات الفعلية**
ID | ป้าย | ข้ามเส้น | ทิศทาง
1 | กข 1234 | ใช่ | ไปทางขวา
2 | ขค 5678 | ใช่ | ไปทางซ้าย
3 | งจ 9999 | ไม่ | -
รถที่ข้ามเส้นทั้งหมด: 2 คัน
12. خطوات استخدام نموذج حقيقي
لاستبدال البيانات المحاكاة بنموذج حقيقي نستخدم Python والمكتبات التالية. لم تُنفَّذ هذه الخطوات على جهاز الاختبار.
1. ثبّت Python 3.12 ثم نفّذ الأمر pip install ultralytics opencv-python
2. حمّل نموذج كشف المركبات واللوحات الذي دُرِّب أو لديك إذن باستخدامه
3. افتح الفيديو عبر OpenCV وأرسل الإطارات إلى النموذج واحدًا تلو الآخر
4. قصّ مربع اللوحة وأرسله إلى نموذج OCR للحصول على النص
5. مرّر النتائج إلى المنطق الموجود في الأمثلة من 2 إلى 10
لم تُنفَّذ في هذه المقالة. تعتمد النتائج الفعلية على النموذج والكاميرا والإضاءة.
الخطوات التالية
الخطوة التالية هي الاختبار بلقطات حقيقية من الكاميرا المركّبة، وقياس الدقة ببيانات موسومة، وضبط حد IoU وموضع الخط وفق زاوية الكاميرا. وعند تداخل المركبات كثيرًا يُستخدم متتبّع يعتمد أيضًا على الحركة، مثل ByteTrack أو DeepSORT.
الخلاصة
يتكون نظام قراءة اللوحات وعدّ المركبات من مطابقة اللوحات بالمركبات، وتتبع المركبات عبر الإطارات، والعدّ عند أول عبور للخط فقط، وتنظيف نص OCR والتصويت عليه عبر الإطارات. كل المنطق هنا كود يعمل فعليًا على بيانات محاكاة.