Contoh Proyek: Membaca Plat Nomor dan Menghitung Kendaraan

Proyek Contoh: 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

Langkah sistem: kamera, deteksi kendaraan dan plat, pelacakan, pencocokan, pembacaan dan voting plat, penghitungan, lalu menyimpan hasil
Langkah sistem: kamera, deteksi kendaraan dan plat, pelacakan, pencocokan, pembacaan dan voting plat, penghitungan, lalu menyimpan hasil

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
Jika dua kendaraan berbagi lajur dan saling tumpang tindih, pelacakan IoU bisa menukar ID mereka, sehingga lajur dipisahkan
Jika dua kendaraan berbagi lajur dan saling tumpang tindih, pelacakan IoU bisa menukar ID mereka, sehingga lajur dipisahkan

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.