サンプルプロジェクト:ナンバープレート認識と車両カウント

サンプルプロジェクト:ナンバープレート認識と車両カウント

ナンバープレートの読み取りと車両のカウントは、防犯カメラで最もよく使われる AI の用途の一つです。システムは各フレームで車両とプレートを見つけ、プレートを正しい車両に対応づけ、文字を読み取り、各車両を一度だけ数える必要があります。この記事では、検出モデルの代わりにシミュレートしたデータを使い、実際に動く Node.js のコードで仕組みを説明します。同じ手順は実際のシステムにも当てはまります。

必要なもの

Node.js 18以降と JavaScript の基礎が必要です。各ファイルは node ファイル名.js で実行します。追加のパッケージは不要です。実際の検出モデルと OCR の実行には Python と ultralytics が必要で、この記事の検証環境では実行していないため、最後の例では手順として紹介します。注意:ここでのプレートの規則は説明用の簡単なものです。使用する地域のプレート形式に合わせて調整してください。また、カメラの設置は設置国のプライバシー法に従う必要があります。

図解

システムの流れ:カメラ、車両とプレートの検出、追跡、対応づけ、プレートの読み取りと投票、カウント、結果の保存
システムの流れ:カメラ、車両とプレートの検出、追跡、対応づけ、プレートの読み取りと投票、カウント、結果の保存

1. システムの概要

システムは6つの段階からなります。カメラからフレームを受け取り、車両とプレートを検出し、フレームをまたいで車両を追跡し、プレートを車両に対応づけ、文字を読み取って整え、最後に数えて結果を保存します。

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 คัน
同じ車線で車両が重なると、IoU による追跡で ID が入れ替わることがあるため、車線を分けます
同じ車線で車両が重なると、IoU による追跡で ID が入れ替わることがあるため、車線を分けます

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 で動画を開き、フレームを1つずつモデルに渡す

4. 検出されたプレートの枠を切り出し、OCR モデルに渡して文字を得る

5. 結果を例2から例10のロジックに渡す

この記事では実行していません。実際の結果はモデル、カメラ、照明に左右されます。

次のステップ

次は、設置したカメラの実際の映像でテストし、ラベル付きデータで精度を測り、カメラの角度に合わせて IoU の閾値と線の位置を調整します。車両の重なりが多い場合は、ByteTrack や DeepSORT のように動きも使う追跡器を使いましょう。

まとめ

ナンバープレート認識と車両カウントのシステムは、プレートを車両に対応づけ、フレームをまたいで車両を追跡し、線を初めて越えたときだけ数え、OCR の文字をフレームをまたいで整えて投票することで成り立ちます。ここでのロジックはすべて、シミュレートしたデータで実際に動くコードです。