示例项目:车牌识别与车辆计数
车牌识别和车辆计数是监控摄像头中最常见的 AI 应用之一。系统需要在每一帧中找到车辆和车牌,把车牌对应到正确的车辆,读取字符,并且让每辆车只被计数一次。本文使用模拟的检测数据代替检测模型,用可以真正运行的 Node.js 代码讲解其中的逻辑,相同的步骤同样适用于实际系统。
需要准备的东西
需要 Node.js 18 或更高版本以及 JavaScript 基础。每个文件用 node 文件名.js 运行,无需安装额外的包。若要运行真实的检测与 OCR 模型需要 Python 和 ultralytics,本文的测试机器上没有运行这部分,因此只在最后一个示例中以步骤的形式介绍。注意:这里的车牌规则只是为演示而设计的简单规则,应根据当地实际的车牌格式进行调整。此外,摄像头的部署必须遵守安装所在国家的隐私法律。
示意图
1. 系统概述
系统分为六个步骤:接收摄像头的帧,检测车辆和车牌,跨帧追踪每辆车,把车牌与车辆对应,读取并整理文字,最后计数并保存结果。
1. 逐帧接收摄像头的画面
2. 在每一帧中检测车辆框和车牌框
3. 追踪车辆,使其在跨帧时保持相同的 ID
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),就沿用原有 ID;否则创建新的 ID。
**代码: 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. 每辆车只计数一次
把追踪与虚拟线结合起来,并记录哪些 ID 已经越过了线。某个 ID 第一次越线时计数一次。如果每一个越线的帧都计数,同一辆车就会被重复计数。
**代码: 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. 把整个系统组合起来
这个示例使用 8 帧的模拟数据。车辆 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 文字。本文的所有逻辑都是在模拟数据上真正运行的代码。