Contoh Proyek: Membaca Plat Nomor dan Menghitung Kendaraan
Membaca plat nomor dan menghitung kendaraan adalah salah satu pekerjaan AI yang paling umum untuk CCTV. Sistem harus menemukan kendaraan dan plat di setiap frame, mencocokkan setiap plat dengan kendaraan yang tepat, membaca karakternya, dan menghitung setiap kendaraan hanya sekali. Artikel ini menjelaskan logikanya dengan kode Node.js yang benar-benar dijalankan, dengan memakai deteksi tiruan sebagai pengganti model deteksi, sehingga logikanya terlihat jelas. Langkah yang sama berlaku untuk sistem sungguhan.
Yang Anda butuhkan
Anda memerlukan Node.js 18 atau yang lebih baru dan pengetahuan dasar JavaScript. Jalankan setiap berkas dengan node namaberkas.js. Tidak diperlukan paket tambahan. Menjalankan model deteksi dan OCR sungguhan memerlukan Python dan ultralytics, yang tidak dijalankan di mesin uji untuk artikel ini, jadi langkahnya hanya ditampilkan di contoh terakhir. Catatan: aturan plat di sini sederhana dan hanya untuk demonstrasi. Sesuaikan dengan format plat yang dipakai di wilayah Anda. Contoh plat memakai format Thailand agar cocok dengan aturan regex yang ditulis. Ingat juga bahwa pemasangan kamera harus mengikuti undang-undang privasi di negara tempat kamera dipasang.
Ilustrasi
1. Gambaran sistem
Sistem memiliki enam langkah: menerima frame dari kamera, mendeteksi kendaraan dan plat, melacak setiap kendaraan lintas frame, mencocokkan plat dengan kendaraan, membaca dan membersihkan teks, lalu menghitung dan menyimpan hasilnya.
1. Terima frame dari kamera satu per satu
2. Deteksi kotak kendaraan dan kotak plat di setiap frame
3. Lacak setiap kendaraan agar tetap memiliki ID yang sama lintas frame
4. Cocokkan setiap plat dengan kendaraan yang kotaknya memuat plat tersebut
5. Baca teks plat, perbaiki karakter yang mudah tertukar, dan lakukan voting lintas frame
6. Hitung kendaraan yang melintasi garis virtual, lalu simpan hasilnya ke berkas
Diagram alur di atas menunjukkan langkah-langkah ini.
2. Mencocokkan plat dengan kendaraan
Plat harus berada di dalam kotak kendaraannya, jadi cocokkan setiap plat dengan memeriksa kotak kendaraan mana yang memuat kotak plat tersebut. Jika tidak ada kotak yang memuatnya, laporkan bahwa tidak ditemukan kendaraan yang cocok.
Kode: ex01_plate_in_car.js
// Mencocokkan plat dengan kendaraan: plat yang berada di dalam kotak kendaraan mana pun dianggap milik kendaraan itu
function contains(outer, inner) {
return inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
}
const cars = [
{ id: "Mobil A", box: [100, 200, 400, 380] },
{ id: "Mobil 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 ? "berada di dalam kotak " + owner.id : "tidak ada mobil yang cocok");
}
Keluaran sebenarnya
กข 1234 berada di dalam kotak Mobil A
ขค 5678 berada di dalam kotak Mobil B
งจ 9999 tidak ada mobil yang cocok
3. Menghitung kendaraan dengan garis virtual
Gambarlah garis vertikal di gambar, lalu hitung kendaraan ketika titik tengahnya berpindah sisi garis. Perubahan tanda pada selisihnya menunjukkan bahwa garis telah dilintasi.
Kode: ex02_line_crossing.js
// Menghitung kendaraan dengan garis virtual: hitung ketika titik tengah kendaraan berpindah dari satu sisi garis ke sisi lainnya
const LINE_X = 400;
// posisi x titik tengah kendaraan di setiap frame
const cars = {
"Mobil A": [300, 340, 380, 420, 460],
"Mobil B": [100, 120, 140, 160],
"Mobil 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;
// tanda yang berbeda berarti garis telah dilintasi
if (before * after < 0) {
total++;
const dir = after > 0 ? "ke kanan" : "ke kiri";
console.log(`${name} melintasi garis di frame ${i} ${dir}`);
}
}
}
console.log("Total keseluruhan", total, "mobil");
Keluaran sebenarnya
Mobil A melintasi garis di frame 3 ke kanan
Mobil C melintasi garis di frame 2 ke kiri
Total keseluruhan 2 mobil
4. Melacak kendaraan lintas frame dengan IoU
Di setiap frame, bandingkan setiap deteksi dengan kotak yang sudah dilacak memakai IoU. Jika tumpang tindihnya cukup besar (di atas 0,3), pertahankan ID yang ada. Jika tidak, buat ID baru.
Kode: ex03_iou_tracker.js
// Melacak kendaraan lintas frame dengan IoU: kotak yang cukup bertumpang tindih dengan kotak lama memakai ID lama, jika tidak ada, dibuat ID baru
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(`Frame ${f}: ID ${ids.join(", ")}`);
});
console.log("Jumlah ID seluruhnya:", nextId - 1);
Keluaran sebenarnya
Frame 0: ID 1, 2
Frame 1: ID 1, 2
Frame 2: ID 1, 2
Frame 3: ID 1
Jumlah ID seluruhnya: 2
5. Menghitung setiap kendaraan hanya sekali
Gabungkan pelacakan dengan garis virtual, dan ingat ID mana yang sudah melintas. Hitung sebuah ID pada pertama kali ia melintasi garis. Jika Anda menghitung setiap frame yang melintasi garis, satu kendaraan akan terhitung berkali-kali.
Kode: ex04_count_unique.js
// Menghitung kendaraan yang unik: lacak kendaraan dengan ID, lalu hitung hanya kali pertama setiap ID melintasi garis
// Jika setiap frame yang melintasi garis dihitung, satu kendaraan akan terhitung berulang kali
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);
}
// Mobil A bergerak ke kanan, Mobil B bergerak ke kiri
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(`Frame ${f}: ID ${best.id} melintasi garis, dihitung sebagai mobil ke-${counted}`);
}
best.lastX = cx;
}
});
console.log("Total mobil yang terhitung:", counted, "mobil");
Keluaran sebenarnya
Frame 6: ID 1 melintasi garis, dihitung sebagai mobil ke-1
Frame 6: ID 2 melintasi garis, dihitung sebagai mobil ke-2
Total mobil yang terhitung: 2 mobil
6. Membersihkan teks dari OCR
OCR sering menghasilkan spasi berlebih dan keliru membaca karakter, misalnya O dengan 0, l dengan 1, dan B dengan 8. Langkah ini menghapus spasi berlebih, memisahkan huruf Thailand dari angka, dan hanya memperbaiki bagian angkanya.
Kode: ex05_clean_text.js
// OCR sering salah baca: ada spasi berlebih, dan huruf tertukar dengan angka, misalnya O dengan 0, l dengan 1
// Langkah ini menghapus spasi, memisahkan bagian huruf dengan angka, lalu memperbaiki karakter yang tertukar hanya pada bagian angka
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));
}
Keluaran sebenarnya
"กข 1234" -> กข 1234
"ก ข 1 2 3 4" -> กข 1234
"กข 1l34" -> กข 1134
"กข 12O4" -> กข 1204
"กข 1B34" -> กข 1834
7. Memeriksa format plat sebelum menerimanya
Gunakan ekspresi reguler untuk memastikan teks sesuai dengan format yang diharapkan. Ini menyaring hasil baca yang jelas salah.
Kode: ex06_validate.js
// Memeriksa format plat sebelum menerima hasilnya: contoh ini memakai pola huruf Thailand 1-3 huruf, satu spasi, lalu angka 1-4 digit
// Ini aturan sederhana untuk demonstrasi, sesuaikan dengan format plat yang benar-benar dipakai di wilayah Anda
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) ? "lolos" : "tidak lolos");
}
Keluaran sebenarnya
กข 1234 lolos
กข1234 tidak lolos
กขค 56 lolos
12 3456 tidak lolos
กข 12345 tidak lolos
งจ 9999 lolos
8. Voting hasil baca dari beberapa frame
Kendaraan yang sama dibaca di banyak frame, dan setiap hasil bacanya mungkin sedikit berbeda. Buang hasil yang tidak sesuai format, lalu pilih hasil yang paling sering muncul.
Kode: ex07_vote.js
// Membaca plat dari beberapa frame lalu memilih hasil yang paling sering muncul (majority vote)
// Satu frame yang salah baca tidak membuat hasil akhir salah, dan hasil yang tidak sesuai format dibuang lebih dulu
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] : "tidak ditemukan";
}
const trackA = ["กข 1234", "กข 1234", "กบ 1234", "กข 1Z34", "กข 1234"];
const trackB = ["ขค 5678", "ขค 5678", "ขค 5678"];
const trackC = ["xyz", "กข 12"];
console.log("Mobil A:", vote(trackA));
console.log("Mobil B:", vote(trackB));
console.log("Mobil C:", vote(trackC));
Keluaran sebenarnya
Mobil A: กข 1234
Mobil B: ขค 5678
Mobil C: กข 12
9. Menyimpan hasil sebagai CSV
Bungkus nilai yang mengandung koma dengan tanda kutip ganda agar kolomnya tidak bergeser.
Kode: ex08_csv.js
// Menyimpan hasil sebagai berkas CSV agar bisa langsung dibuka di program spreadsheet
// Nilai yang mengandung tanda koma atau baris baru harus diapit tanda kutip
function toCsvLine(values) {
return values
.map(v => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
})
.join(",");
}
const header = ["id", "plat", "frame pertama terlihat", "arah", "catatan"];
const rows = [
[1, "กข 1234", 0, "ke kanan", "melintasi garis di frame 6"],
[2, "ขค 5678", 0, "ke kiri", "melintasi garis, plat salah baca 1 frame"],
[3, "งจ 9999", 0, "-", "parkir, tidak melintasi garis"],
];
console.log(toCsvLine(header));
for (const r of rows) console.log(toCsvLine(r));
Keluaran sebenarnya
id,plat,frame pertama terlihat,arah,catatan
1,กข 1234,0,ke kanan,melintasi garis di frame 6
2,ขค 5678,0,ke kiri,"melintasi garis, plat salah baca 1 frame"
3,งจ 9999,0,-,"parkir, tidak melintasi garis"
10. Merangkum kendaraan per jam
Ambil waktu setiap kejadian yang terhitung, kelompokkan kejadian per jam, lalu lihat jam mana yang paling ramai.
Kode: ex09_hourly.js
// Merangkum jumlah kendaraan per jam: ambil waktu setiap kejadian yang terhitung, lalu kelompokkan berdasarkan jam
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} mobil`);
}
console.log("Total keseluruhan", events.length, "mobil");
Keluaran sebenarnya
08:00 - 08:59 ### 3 mobil
09:00 - 09:59 ### 3 mobil
10:00 - 10:59 ### 3 mobil
Total keseluruhan 9 mobil
11. Menyatukan seluruh sistem
Contoh ini memakai delapan frame data tiruan. Kendaraan A dan B bergerak di lajurnya masing-masing, dan kendaraan C sedang parkir. Contoh ini menjalankan semua langkah: pelacakan, pencocokan, pembersihan, voting, dan penghitungan.
Kode: ex10_full_pipeline.js
// Contoh gabungan: data tiruan tiap frame -> lacak kendaraan -> cocokkan plat -> bersihkan teks plat -> voting hasil -> hitung kendaraan yang melintasi garis
// Pada sistem sungguhan, data tiap frame berasal dari model deteksi dan OCR, bukan dibuat sendiri
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] : "tidak ditemukan";
}
// data tiruan 8 frame: Mobil A ke kanan, Mobil B ke kiri, Mobil C parkir
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 ? "ke kanan" : "ke kiri";
}
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 | plat | melintasi garis | arah");
for (const t of tracks) {
console.log(`${t.id} | ${vote(t.plates)} | ${t.crossed ? "ya" : "tidak"} | ${t.dir}`);
}
console.log("Total mobil yang melintasi garis:", tracks.filter(t => t.crossed).length, "mobil");
Keluaran sebenarnya
ID | plat | melintasi garis | arah
1 | กข 1234 | ya | ke kanan
2 | ขค 5678 | ya | ke kiri
3 | งจ 9999 | tidak | -
Total mobil yang melintasi garis: 2 mobil
12. Langkah untuk model sungguhan
Untuk mengganti data tiruan dengan model sungguhan, gunakan Python dan pustaka berikut. Langkah-langkah ini tidak dijalankan di mesin uji.
1. Pasang Python 3.12 lalu jalankan pip install ultralytics opencv-python
2. Muat model deteksi kendaraan dan plat yang sudah Anda latih atau yang Anda punya izin untuk memakainya
3. Buka video dengan OpenCV dan kirim satu frame setiap kali ke model
4. Potong setiap kotak plat, kirim ke model OCR, dan dapatkan teksnya
5. Teruskan hasilnya melalui logika pada contoh 2 sampai 10
Tidak dijalankan dalam artikel ini. Hasil sungguhan bergantung pada model, kamera, dan pencahayaan.
Langkah berikutnya
Langkah berikutnya adalah menguji dengan rekaman sungguhan dari kamera yang terpasang, mengukur akurasi dengan data yang sudah diberi label, menyetel ambang IoU dan posisi garis sesuai sudut kamera, dan ketika kendaraan sering bertumpukan, menggunakan pelacak yang juga memakai gerakan, seperti ByteTrack atau DeepSORT.
Ringkasan
Sistem pembaca plat dan penghitung kendaraan menggabungkan pencocokan plat dengan kendaraan, pelacakan kendaraan lintas frame, penghitungan setiap kendaraan pada kali pertama melintasi garis, serta pembersihan dan voting teks OCR lintas frame. Semua logika di sini adalah kode yang benar-benar dijalankan pada data tiruan.