Projeto de Exemplo: Lendo Placas de Carro e Contando Veículos

Projeto de Exemplo: Lendo Placas de Carro e Contando Veículos

Ler placas e contar veículos é uma das tarefas de IA mais comuns em câmeras de segurança. O sistema precisa encontrar veículos e placas em cada quadro, associar cada placa ao veículo certo, ler os caracteres e contar cada veículo apenas uma vez. Este artigo explica a lógica com código Node.js que realmente foi executado, usando detecções simuladas no lugar de um modelo de detecção, para que a lógica fique clara. Os mesmos passos valem para um sistema real.

O que você precisa

Você precisa de Node.js 18 ou mais recente e de conhecimento básico de JavaScript. Execute cada arquivo com node nomedoarquivo.js. Não são necessários pacotes adicionais. Executar modelos reais de detecção e OCR exige Python e ultralytics, que não foram executados na máquina de teste deste artigo, então essas etapas aparecem apenas no último exemplo. Observação: as regras de placa aqui são simples e servem só para demonstração. Ajuste-as ao formato de placa usado na sua região. As placas de exemplo usam o formato tailandês para combinar com as regras de regex escritas. Lembre também que a instalação de câmeras deve seguir as leis de privacidade do país onde elas estão.

Ilustração

Etapas do sistema: câmera, detecção de veículos e placas, rastreamento, associação, leitura e votação das placas, contagem, e então o salvamento dos resultados
Etapas do sistema: câmera, detecção de veículos e placas, rastreamento, associação, leitura e votação das placas, contagem, e então o salvamento dos resultados

1. Visão geral do sistema

O sistema tem seis etapas: receber quadros da câmera, detectar veículos e placas, rastrear cada veículo entre os quadros, associar cada placa a um veículo, ler e limpar o texto e então contar e salvar os resultados.

1. Receber os quadros da câmera um a um

2. Detectar as caixas de veículos e as caixas de placas em cada quadro

3. Rastrear cada veículo para que ele mantenha o mesmo ID entre os quadros

4. Associar cada placa ao veículo cuja caixa contém essa placa

5. Ler o texto da placa, corrigir caracteres facilmente confundidos e fazer votação entre os quadros

6. Contar os veículos que cruzam uma linha virtual e salvar os resultados em um arquivo

O diagrama de fluxo acima mostra essas etapas.

2. Associando placas a veículos

A placa precisa estar dentro da caixa do seu veículo, então associe cada placa verificando qual caixa de veículo contém a caixa da placa. Se nenhuma caixa a contiver, informe que não há veículo correspondente.

Código: ex01_plate_in_car.js

// Associando placa e veículo: a placa que está dentro da caixa de qualquer veículo é considerada desse veículo
function contains(outer, inner) {
  return inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
}

const cars = [
  { id: "Carro A", box: [100, 200, 400, 380] },
  { id: "Carro 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 ? "está dentro da caixa de " + owner.id : "nenhum carro correspondente");
}

Saída real

กข 1234   está dentro da caixa de Carro A
ขค 5678   está dentro da caixa de Carro B
งจ 9999   nenhum carro correspondente

3. Contando veículos com uma linha virtual

Desenhe uma linha vertical na imagem e conte um veículo quando o seu ponto central mudar de lado em relação à linha. Uma mudança de sinal na diferença indica que a linha foi cruzada.

Código: ex02_line_crossing.js

// Contando veículos com uma linha virtual: conte quando o ponto central do veículo passa de um lado da linha para o outro
const LINE_X = 400;

// posição x do ponto central de cada veículo em cada quadro
const cars = {
  "Carro A": [300, 340, 380, 420, 460],
  "Carro B": [100, 120, 140, 160],
  "Carro 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;
    // sinais diferentes significam que a linha foi cruzada
    if (before * after < 0) {
      total++;
      const dir = after > 0 ? "para a direita" : "para a esquerda";
      console.log(`${name} cruzou a linha no quadro ${i} ${dir}`);
    }
  }
}
console.log("Total geral", total, "carros");

Saída real

Carro A cruzou a linha no quadro 3 para a direita
Carro C cruzou a linha no quadro 2 para a esquerda
Total geral 2 carros

4. Rastreando veículos entre quadros com IoU

Em cada quadro, compare cada detecção com as caixas já rastreadas usando IoU. Se a sobreposição for suficientemente grande (acima de 0,3), mantenha o ID existente. Caso contrário, crie um novo ID.

Código: ex03_iou_tracker.js

// Rastreando veículos entre quadros com IoU: a caixa que se sobrepõe o suficiente a uma caixa antiga recebe o mesmo ID, senão recebe um ID novo
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(`Quadro ${f}: ID ${ids.join(", ")}`);
});
console.log("Total de IDs:", nextId - 1);

Saída real

Quadro 0: ID 1, 2
Quadro 1: ID 1, 2
Quadro 2: ID 1, 2
Quadro 3: ID 1
Total de IDs: 2

5. Contando cada veículo apenas uma vez

Combine o rastreamento com a linha virtual e lembre quais IDs já cruzaram. Conte um ID na primeira vez que ele cruza a linha. Se você contar cada quadro em que o veículo está do outro lado da linha, um mesmo carro será contado várias vezes.

Código: ex04_count_unique.js

// Contando veículos únicos: rastreie cada veículo com um ID e conte apenas a primeira vez que cada ID cruza a linha
// Se cada quadro em que houver cruzamento for contado, um mesmo veículo será contado várias vezes
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);
}

// Carro A se move para a direita, Carro B se move para a esquerda
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(`Quadro ${f}: ID ${best.id} cruzou a linha, contado como carro nº ${counted}`);
    }
    best.lastX = cx;
  }
});
console.log("Total de carros contados:", counted, "carros");

Saída real

Quadro 6: ID 1 cruzou a linha, contado como carro nº 1
Quadro 6: ID 2 cruzou a linha, contado como carro nº 2
Total de carros contados: 2 carros
Se dois veículos dividem a mesma faixa e se sobrepõem, o rastreamento por IoU pode trocar os IDs deles, então as faixas precisam ser separadas
Se dois veículos dividem a mesma faixa e se sobrepõem, o rastreamento por IoU pode trocar os IDs deles, então as faixas precisam ser separadas

6. Limpando o texto do OCR

O OCR costuma gerar espaços extras e confundir caracteres, por exemplo O com 0, l com 1 e B com 8. Esta etapa remove os espaços extras, separa as letras tailandesas dos números e corrige apenas a parte numérica.

Código: ex05_clean_text.js

// O OCR costuma errar: há espaços extras, e letras são trocadas por números, por exemplo O por 0, l por 1
// Esta etapa remove os espaços, separa a parte de letras da parte numérica, e corrige os caracteres trocados somente na parte numérica
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));
}

Saída real

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

7. Verificando o formato da placa antes de aceitá-la

Use uma expressão regular para confirmar que o texto segue o formato esperado. Isso filtra leituras que claramente estão erradas.

Código: ex06_validate.js

// Verificando o formato da placa antes de aceitar o resultado: este exemplo usa o padrão de 1 a 3 letras tailandesas, um espaço e de 1 a 4 dígitos
// É uma regra simples para demonstração, ajuste ao formato de placa realmente usado na sua região
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) ? "aprovado" : "reprovado");
}

Saída real

กข 1234    aprovado
กข1234     reprovado
กขค 56     aprovado
12 3456    reprovado
กข 12345   reprovado
งจ 9999    aprovado

8. Votação entre as leituras de vários quadros

O mesmo veículo é lido em vários quadros, e cada leitura pode diferir um pouco. Descarte as leituras que não seguem o formato e escolha a que aparece com mais frequência.

Código: ex07_vote.js

// Lendo a placa em vários quadros e escolhendo o resultado que mais aparece (votação por maioria)
// Um quadro com leitura errada não torna o resultado final errado, e leituras fora do formato são descartadas antes
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] : "não encontrado";
}

const trackA = ["กข 1234", "กข 1234", "กบ 1234", "กข 1Z34", "กข 1234"];
const trackB = ["ขค 5678", "ขค 5678", "ขค 5678"];
const trackC = ["xyz", "กข 12"];

console.log("Carro A:", vote(trackA));
console.log("Carro B:", vote(trackB));
console.log("Carro C:", vote(trackC));

Saída real

Carro A: กข 1234
Carro B: ขค 5678
Carro C: กข 12

9. Salvando os resultados em CSV

Coloque entre aspas duplas os valores que contêm vírgula, para que as colunas não se desloquem.

Código: ex08_csv.js

// Salvando os resultados em um arquivo CSV, para abrir direto em um programa de planilha
// Valores que contêm vírgula ou quebra de linha precisam estar entre aspas
function toCsvLine(values) {
  return values
    .map(v => {
      const s = String(v);
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    })
    .join(",");
}

const header = ["id", "placa", "primeiro quadro visto", "direção", "observação"];
const rows = [
  [1, "กข 1234", 0, "para a direita", "cruzou a linha no quadro 6"],
  [2, "ขค 5678", 0, "para a esquerda", "cruzou a linha, placa lida errada em 1 quadro"],
  [3, "งจ 9999", 0, "-", "estacionado, não cruzou a linha"],
];

console.log(toCsvLine(header));
for (const r of rows) console.log(toCsvLine(r));

Saída real

id,placa,primeiro quadro visto,direção,observação
1,กข 1234,0,para a direita,cruzou a linha no quadro 6
2,ขค 5678,0,para a esquerda,"cruzou a linha, placa lida errada em 1 quadro"
3,งจ 9999,0,-,"estacionado, não cruzou a linha"

10. Resumindo os veículos por hora

Pegue o horário de cada evento contado, agrupe os eventos por hora e veja em qual hora houve mais movimento.

Código: ex09_hourly.js

// Resumindo a quantidade de veículos por hora: pegue o horário de cada evento contado e agrupe pela hora
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} carros`);
}
console.log("Total geral", events.length, "carros");

Saída real

08:00 - 08:59  ### 3 carros
09:00 - 09:59  ### 3 carros
10:00 - 10:59  ### 3 carros
Total geral 9 carros

11. Juntando o sistema inteiro

Este exemplo usa oito quadros de dados simulados. O Carro A e o Carro B se movem cada um na sua faixa, e o Carro C está estacionado. O exemplo executa todas as etapas: rastreamento, associação, limpeza, votação e contagem.

Código: ex10_full_pipeline.js

// Exemplo combinado: dados simulados de cada quadro -> rastrear veículos -> associar placas -> limpar o texto das placas -> votar o resultado -> contar veículos que cruzam a linha
// Em um sistema real, os dados de cada quadro vêm do modelo de detecção e do OCR, e não são criados à mão
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] : "não encontrado";
}

// dados simulados de 8 quadros: Carro A para a direita, Carro B para a esquerda, Carro C estacionado
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 ? "para a direita" : "para a esquerda";
    }
    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 | placa | cruzou a linha | direção");
for (const t of tracks) {
  console.log(`${t.id} | ${vote(t.plates)} | ${t.crossed ? "sim" : "não"} | ${t.dir}`);
}
console.log("Total de carros que cruzaram a linha:", tracks.filter(t => t.crossed).length, "carros");

Saída real

ID | placa | cruzou a linha | direção
1 | กข 1234 | sim | para a direita
2 | ขค 5678 | sim | para a esquerda
3 | งจ 9999 | não | -
Total de carros que cruzaram a linha: 2 carros

12. Passos para um modelo real

Para trocar os dados simulados por um modelo real, use Python e as bibliotecas abaixo. Estes passos não foram executados na máquina de teste.

1. Instale o Python 3.12 e execute pip install ultralytics opencv-python

2. Carregue um modelo de detecção de veículos e placas que você treinou ou para o qual tem permissão de uso

3. Abra o vídeo com OpenCV e envie um quadro por vez ao modelo

4. Recorte cada caixa de placa, envie ao modelo de OCR e obtenha o texto

5. Passe os resultados pela lógica dos exemplos 2 a 10

Não executado neste artigo. Os resultados reais dependem do modelo, da câmera e da iluminação.

Próximos passos

O próximo passo é testar com gravações reais da câmera instalada, medir a precisão com dados rotulados, ajustar o limite de IoU e a posição da linha conforme o ângulo da câmera e, quando os veículos se sobrepõem com frequência, usar um rastreador que também considere o movimento, como ByteTrack ou DeepSORT.

Resumo

Um sistema de leitura de placas e contagem de veículos combina a associação de placas com veículos, o rastreamento de veículos entre quadros, a contagem de cada veículo na primeira vez que cruza a linha, e a limpeza e votação do texto do OCR entre quadros. Toda a lógica aqui é código que realmente foi executado com dados simulados.