A Sample Project: Reading License Plates and Counting Vehicles

A Sample Project: Reading License Plates and Counting Vehicles

Reading license plates and counting vehicles is one of the most common AI jobs for CCTV. The system must find vehicles and plates in each frame, match each plate to the right vehicle, read the characters, and count each vehicle only once. This article explains the logic with Node.js code that runs for real, using simulated detections in place of a detection model, so the logic can be seen clearly. The same steps apply to a real system.

What you need

You need Node.js 18 or later and basic JavaScript. Run each file with node filename.js. No extra packages are needed. Running a real detection and OCR model needs Python and ultralytics, which were not run on the test machine for this article, so they appear in the last example as steps only. Note: the plate rule here is a simple one for demonstration. Adjust it to the plate formats used in your area, and remember that camera deployments must follow the privacy laws of the country where they are installed.

Illustrations

The system steps: camera, detect vehicles and plates, track, match, read and vote on plates, count, save results
The system steps: camera, detect vehicles and plates, track, match, read and vote on plates, count, save results

1. Overview of the system

The system has six steps: receive frames from the camera, detect vehicles and plates, track each vehicle across frames, match plates to vehicles, read and clean the text, then count and save the results.

1. Receive frames from the camera one by one

2. Detect vehicle boxes and plate boxes in each frame

3. Track each vehicle so it keeps the same ID across frames

4. Match each plate to the vehicle whose box contains it

5. Read the plate text, fix confusable characters, and vote across frames

6. Count vehicles that cross a virtual line, then save the results to a file

The flow diagram above shows these steps.

2. Matching a plate to a vehicle

A plate must lie inside its vehicle's box, so match each plate by checking which vehicle box contains the plate box. If no box contains it, report that no matching vehicle was found.

**Code: 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 : "ไม่พบรถที่ตรงกัน");
}

**Real output**

กข 1234   อยู่ในกรอบ รถ A
ขค 5678   อยู่ในกรอบ รถ B
งจ 9999   ไม่พบรถที่ตรงกัน

3. Counting vehicles with a virtual line

Draw a vertical line in the image and count a vehicle when its centre point changes side of the line. A change of sign in the difference shows that the line was crossed.

**Code: 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, "คัน");

**Real output**

รถ A ข้ามเส้นที่เฟรม 3 ไปทางขวา
รถ C ข้ามเส้นที่เฟรม 2 ไปทางซ้าย
รวมทั้งหมด 2 คัน

4. Tracking vehicles across frames with IoU

In each frame, compare each detection with the tracked boxes using IoU. If the overlap is high enough (above 0.3), keep the existing ID. If not, create a new ID.

**Code: 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);

**Real output**

เฟรม 0: ID 1, 2
เฟรม 1: ID 1, 2
เฟรม 2: ID 1, 2
เฟรม 3: ID 1
จำนวน ID ทั้งหมด: 2

5. Counting each vehicle only once

Combine the tracking with the virtual line and remember which IDs have already crossed. Count an ID the first time it crosses. If you count every crossing frame, one vehicle will be counted many times.

**Code: 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, "คัน");

**Real output**

เฟรม 6: ID 1 ข้ามเส้น นับเป็นคันที่ 1
เฟรม 6: ID 2 ข้ามเส้น นับเป็นคันที่ 2
รถที่นับได้ทั้งหมด: 2 คัน
If two vehicles share a lane and overlap, IoU tracking can swap their IDs, so separate lanes are used
If two vehicles share a lane and overlap, IoU tracking can swap their IDs, so separate lanes are used

6. Cleaning text from OCR

OCR often produces extra spaces and confuses characters, for example O with 0, l with 1, and B with 8. This step removes extra spaces, separates the Thai letters from the digits, and corrects only the digit part.

**Code: 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));
}

**Real output**

"กข  1234"       -> กข 1234
"ก ข 1 2 3 4"    -> กข 1234
"กข 1l34"        -> กข 1134
"กข 12O4"        -> กข 1204
"กข 1B34"        -> กข 1834

7. Checking the plate format before accepting it

Use a regular expression to check that the text fits the expected format. This filters out clearly wrong readings.

**Code: 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) ? "ผ่าน" : "ไม่ผ่าน");
}

**Real output**

กข 1234    ผ่าน
กข1234     ไม่ผ่าน
กขค 56     ผ่าน
12 3456    ไม่ผ่าน
กข 12345   ไม่ผ่าน
งจ 9999    ผ่าน

8. Voting on readings from several frames

The same vehicle is read in many frames, and each reading may differ slightly. Remove readings that do not match the format, then choose the one that appears most often.

**Code: 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));

**Real output**

รถ A: กข 1234
รถ B: ขค 5678
รถ C: กข 12

9. Saving results as CSV

Wrap values that contain commas in double quotes so that the columns do not shift.

**Code: 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));

**Real output**

id,ป้าย,เฟรมที่เห็นครั้งแรก,ทิศทาง,หมายเหตุ
1,กข 1234,0,ไปทางขวา,ข้ามเส้นที่เฟรม 6
2,ขค 5678,0,ไปทางซ้าย,"ข้ามเส้น, ป้ายอ่านผิด 1 เฟรม"
3,งจ 9999,0,-,"จอดอยู่, ไม่ข้ามเส้น"

10. Summarising vehicles per hour

Take the time of each counted event, group the events by hour, and see which hours have the most traffic.

**Code: 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, "คัน");

**Real output**

08:00 - 08:59  ### 3 คัน
09:00 - 09:59  ### 3 คัน
10:00 - 10:59  ### 3 คัน
รวมทั้งหมด 9 คัน

11. Putting the whole system together

This example uses eight frames of simulated data. Vehicles A and B move in their own lanes, and vehicle C is parked. It runs every step: tracking, matching, cleaning, voting and counting.

**Code: 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, "คัน");

**Real output**

ID | ป้าย | ข้ามเส้น | ทิศทาง
1 | กข 1234 | ใช่ | ไปทางขวา
2 | ขค 5678 | ใช่ | ไปทางซ้าย
3 | งจ 9999 | ไม่ | -
รถที่ข้ามเส้นทั้งหมด: 2 คัน

12. Steps for a real model

To replace the simulated data with a real model, use Python and the libraries below. These steps were not run on the test machine.

1. Install Python 3.12 and run pip install ultralytics opencv-python

2. Load a vehicle and plate detection model that you have trained or have permission to use

3. Open the video with OpenCV and send one frame at a time to the model

4. Crop each plate box, send it to an OCR model, and get the text

5. Pass the results through the logic in examples 2 to 10

Not run in this article. Real results depend on the model, the camera and the lighting.

Next steps

The next step is to test with real footage from the installed camera, measure accuracy on labelled data, tune the IoU threshold and the line position for the camera angle, and, when vehicles overlap a lot, use a tracker that also uses motion, such as ByteTrack or DeepSORT.

Summary

A plate-reading and vehicle-counting system combines matching plates to vehicles, tracking vehicles across frames, counting each vehicle the first time it crosses the line, and cleaning and voting on OCR text across frames. All the logic here is code that runs for real on simulated data.