YOLOによる物体検出:画像と動画から物体を見つける

YOLOによる物体検出:画像と動画から物体を見つける

物体検出は、画像の中にどんな物体があり、どこにあるかを見つけます。結果はバウンディングボックスとクラス名、信頼度スコアです。YOLOシリーズのモデルは動画にも使えるほど高速です。この記事では、その背景にある考え方を説明します。ボックス座標、IoU、NMS、グリッドセルの読み取り、letterboxによるリサイズ、precision、recall、APによる評価です。すべての例は標準ライブラリだけを使うJavaScriptなので、モデルをダウンロードせずにすぐ実行できます。

必要なもの

Node.js 18以降と、JavaScriptの基礎が必要です。各例は node ファイル名.js で実行します。追加のパッケージは不要です。YOLOモデルを実際の画像で動かすにはPythonとultralyticsライブラリが必要です。この記事の検証環境では実行していないため、次のステップで紹介するだけにしています。

図解

物体検出の流れ:画像、グリッド上でモデルが予測、フィルタリングとNMS、元の画像にボックス
物体検出の流れ:画像、グリッド上でモデルが予測、フィルタリングとNMS、元の画像にボックス

例 1: 画像は数値の配列

デジタル画像はピクセル値の格子なので、モデルは数値を扱います。この例では4x4の白黒画像を作り、明るいピクセルを数えます。

// ภาพขาวดำ 4x4 เก็บเป็นอาร์เรย์ของค่าความสว่าง 0-255
const image = [
  [0, 0, 255, 255],
  [0, 0, 255, 255],
  [0, 0, 0, 0],
  [0, 0, 0, 0],
];
const height = image.length;
const width = image[0].length;
console.log(`ขนาดภาพ: กว้าง ${width} x สูง ${height}`);
let bright = 0;
for (const row of image) for (const p of row) if (p > 127) bright++;
console.log("จำนวนพิกเซลสว่าง:", bright);
console.log("ค่าพิกเซลมุมบนซ้าย:", image[0][0]);

**実際の出力**

ขนาดภาพ: กว้าง 4 x สูง 4
จำนวนพิกเซลสว่าง: 4
ค่าพิกเซลมุมบนซ้าย: 0

例 2: ボックス形式の変換

YOLOは中心点と幅・高さ(cx, cy, w, h)を返します。描画やIoUの計算では左上と右下の座標(x1, y1, x2, y2)がよく使われます。

// โมเดล YOLO คืนกรอบเป็น (cx, cy, w, h) แต่การวาดและคำนวณมักใช้ (x1, y1, x2, y2)
function xywhToXyxy([cx, cy, w, h]) {
  return [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2];
}
const box = [320, 240, 100, 50];
const [x1, y1, x2, y2] = xywhToXyxy(box);
console.log("มุมบนซ้าย:", x1, y1);
console.log("มุมล่างขวา:", x2, y2);
console.log("กว้าง:", x2 - x1, "สูง:", y2 - y1);

**実際の出力**

มุมบนซ้าย: 270 215
มุมล่างขวา: 370 265
กว้าง: 100 สูง: 50

例 3: IoUでボックスの重なりを測る

IoUは重なった面積を全体の面積で割った値で、0から1の範囲です。1はボックスが完全に一致することを表します。

// IoU = พื้นที่ที่ซ้อนกัน / พื้นที่รวมของทั้งสองกรอบ
function area([x1, y1, x2, y2]) {
  return Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
}
function iou(a, b) {
  const ix1 = Math.max(a[0], b[0]);
  const iy1 = Math.max(a[1], b[1]);
  const ix2 = Math.min(a[2], b[2]);
  const iy2 = Math.min(a[3], b[3]);
  const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
  return inter / (area(a) + area(b) - inter);
}
const a = [0, 0, 100, 100];
console.log("กรอบเดียวกัน:", iou(a, a).toFixed(2));
console.log("ซ้อนกันหนึ่งในสาม:", iou(a, [50, 0, 150, 100]).toFixed(2));
console.log("ไม่ซ้อนกัน:", iou(a, [200, 200, 300, 300]).toFixed(2));

**実際の出力**

กรอบเดียวกัน: 1.00
ซ้อนกันหนึ่งในสาม: 0.33
ไม่ซ้อนกัน: 0.00

例 4: 信頼度で絞り込む

モデルは多くのボックスを予測します。信頼度が閾値より低いものは次の段階に進む前に捨てます。

// กรองกรอบที่มั่นใจน้อยออกก่อน ด้วยเกณฑ์ความมั่นใจ (confidence threshold)
const detections = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "car", score: 0.31, box: [200, 70, 260, 130] },
  { label: "person", score: 0.88, box: [300, 50, 340, 170] },
  { label: "dog", score: 0.12, box: [10, 150, 60, 200] },
];
const THRESHOLD = 0.5;
const kept = detections.filter(d => d.score >= THRESHOLD);
console.log(`เหลือ ${kept.length} จาก ${detections.length} กรอบ (เกณฑ์ ${THRESHOLD})`);
for (const d of kept) console.log(d.label, d.score.toFixed(2));

**実際の出力**

เหลือ 2 จาก 4 กรอบ (เกณฑ์ 0.5)
car 0.94
person 0.88

例 5: Non-Maximum Suppression(NMS)

NMSは信頼度順に並べ、最も高いボックスを残し、それとIoUが大きい他のボックスを削除します。結果として物体1つにつきボックス1つになります。

// Non-Maximum Suppression: เก็บกรอบที่มั่นใจสุด และตัดกรอบที่ซ้อนทับกันมากออก
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);
  const areaA = (a[2] - a[0]) * (a[3] - a[1]);
  const areaB = (b[2] - b[0]) * (b[3] - b[1]);
  return inter / (areaA + areaB - inter);
}
function nms(dets, iouThreshold) {
  const sorted = [...dets].sort((p, q) => q.score - p.score);
  const keep = [];
  for (const d of sorted) {
    if (keep.every(k => iou(k.box, d.box) < iouThreshold)) keep.push(d);
  }
  return keep;
}
const dets = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "car", score: 0.81, box: [45, 62, 185, 142] },
  { label: "car", score: 0.67, box: [200, 70, 260, 130] },
  { label: "person", score: 0.88, box: [300, 50, 340, 170] },
];
const result = nms(dets, 0.5);
console.log(`ก่อน NMS ${dets.length} กรอบ -> หลัง NMS ${result.length} กรอบ`);
for (const d of result) console.log(d.label, d.score.toFixed(2));

**実際の出力**

ก่อน NMS 4 กรอบ -> หลัง NMS 3 กรอบ
car 0.94
person 0.88
car 0.67
IoUとNMS:重なりの大きいボックスを除き、最も確信度の高いものを残す
IoUとNMS:重なりの大きいボックスを除き、最も確信度の高いものを残す

例 6: クラスごとのNMS

全クラスをまとめてNMSすると、重なった車と犬の一方が消えることがあります。クラスごとに分けて実行します。

// NMS แยกตามคลาส: กรอบของคนละคลาสที่ซ้อนกันยังเก็บไว้ทั้งคู่
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 nms(dets, iouThreshold) {
  const sorted = [...dets].sort((p, q) => q.score - p.score);
  const keep = [];
  for (const d of sorted) if (keep.every(k => iou(k.box, d.box) < iouThreshold)) keep.push(d);
  return keep;
}
function nmsPerClass(dets, iouThreshold) {
  const groups = {};
  for (const d of dets) (groups[d.label] = groups[d.label] || []).push(d);
  return Object.values(groups).flatMap(g => nms(g, iouThreshold));
}
const dets = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "dog", score: 0.90, box: [50, 65, 170, 150] },
  { label: "car", score: 0.85, box: [45, 62, 185, 142] },
];
const result = nmsPerClass(dets, 0.5);
console.log("หลัง NMS แยกคลาส:");
for (const d of result) console.log(d.label, d.score.toFixed(2));

**実際の出力**

หลัง NMS แยกคลาส:
car 0.94
dog 0.90

例 7: グリッドセルの読み取り

YOLOは画像を格子に分け、各セルが生の値を予測します。中心にはsigmoid、幅と高さにはexpを適用すると、ピクセル単位の座標になります。

// YOLO แบ่งภาพเป็นตาราง แต่ละเซลล์ทำนายกรอบออกมา ค่าดิบต้องแปลงด้วย sigmoid และ exp
const sigmoid = x => 1 / (1 + Math.exp(-x));
const STRIDE = 100; // ภาพ 400x400 แบ่งเป็น 4x4 เซลล์ ขนาดเซลล์ละ 100 พิกเซล
const raw = [
  { name: "เซลล์ A", row: 1, col: 2, tx: 0.2, ty: -0.4, tw: 0.0, th: 0.5, obj: 3.0 },
  { name: "เซลล์ B", row: 3, col: 0, tx: -1.5, ty: 1.0, tw: -0.3, th: 0.1, obj: -2.0 },
];
for (const r of raw) {
  const cx = (r.col + sigmoid(r.tx)) * STRIDE;
  const cy = (r.row + sigmoid(r.ty)) * STRIDE;
  const w = Math.exp(r.tw) * STRIDE;
  const h = Math.exp(r.th) * STRIDE;
  const conf = sigmoid(r.obj);
  console.log(`${r.name}: cx=${cx.toFixed(1)} cy=${cy.toFixed(1)} w=${w.toFixed(1)} h=${h.toFixed(1)} conf=${conf.toFixed(2)}`);
}

**実際の出力**

เซลล์ A: cx=255.0 cy=140.1 w=100.0 h=164.9 conf=0.95
เซลล์ B: cx=18.2 cy=373.1 w=74.1 h=110.5 conf=0.12

例 8: Softmaxでクラスを選ぶ

生のクラススコアを合計100パーセントになる確率に変換します。最も高いものが予測結果です。

// แปลงคะแนนดิบของแต่ละคลาสเป็นความน่าจะเป็นด้วย softmax แล้วเลือกคลาสที่สูงสุด
const names = ["person", "car", "dog"];
const logits = [1.2, 3.1, 0.4];
const exps = logits.map(Math.exp);
const sum = exps.reduce((a, b) => a + b, 0);
const probs = exps.map(e => e / sum);
probs.forEach((p, i) => console.log(names[i].padEnd(8), (p * 100).toFixed(1) + "%"));
const best = probs.indexOf(Math.max(...probs));
console.log("ผลทาย:", names[best]);

**実際の出力**

person   12.3%
car      82.2%
dog      5.5%
ผลทาย: car

例 9: Letterboxと座標の逆変換

モデルは640x640などの固定サイズを受け取るため、縦横比を保って縮小し余白を埋めます。検出後は余白を取り除き、倍率で割って元のサイズに戻します。

// โมเดลรับภาพขนาดคงที่ 640x640 จึงต้องย่อโดยรักษาสัดส่วน แล้วเติมขอบดำ (letterbox)
// และต้องแปลงพิกัดกรอบกลับไปยังขนาดภาพจริง
const orig = { w: 1280, h: 720 };
const INPUT = 640;
const scale = Math.min(INPUT / orig.w, INPUT / orig.h);
const newW = Math.round(orig.w * scale);
const newH = Math.round(orig.h * scale);
const padX = (INPUT - newW) / 2;
const padY = (INPUT - newH) / 2;
console.log(`scale=${scale} ขนาดใหม่=${newW}x${newH} padX=${padX} padY=${padY}`);
// กรอบที่โมเดลทำนายในพื้นที่ 640x640
const boxInInput = [100, 200, 300, 400];
const back = [
  (boxInInput[0] - padX) / scale,
  (boxInInput[1] - padY) / scale,
  (boxInInput[2] - padX) / scale,
  (boxInInput[3] - padY) / scale,
];
console.log("กรอบในภาพจริง:", back.map(v => Math.round(v)));

**実際の出力**

scale=0.5 ขนาดใหม่=640x360 padX=0 padY=140
กรอบในภาพจริง: [ 200, 120, 600, 520 ]

例 10: 文字でボックスを描く

この例では40x12文字のキャンバスにボックスを描き、グラフィックライブラリなしで物体の位置を確認します。

// วาดกรอบลงบนผืนผ้าใบตัวอักษร เพื่อดูผลการตรวจจับโดยไม่ต้องใช้ไลบรารีกราฟิก
// ภาพจริง 400x240 พิกเซล ย่อเป็นตาราง 40x12 ช่อง (ช่องละ 10x20 พิกเซล)
const COLS = 40, ROWS = 12, SX = 10, SY = 20;
const canvas = Array.from({ length: ROWS }, () => Array(COLS).fill("."));
const dets = [
  { label: "car", box: [40, 60, 180, 140] },
  { label: "person", box: [300, 50, 340, 170] },
];
for (const d of dets) {
  const [x1, y1, x2, y2] = d.box;
  const c1 = Math.floor(x1 / SX), c2 = Math.floor(x2 / SX);
  const r1 = Math.floor(y1 / SY), r2 = Math.floor(y2 / SY);
  for (let c = c1; c <= c2; c++) { canvas[r1][c] = "-"; canvas[r2][c] = "-"; }
  for (let r = r1; r <= r2; r++) { canvas[r][c1] = "|"; canvas[r][c2] = "|"; }
  canvas[r1][c1] = d.label[0].toUpperCase();
}
console.log(canvas.map(row => row.join("")).join("\n"));

**実際の出力**

........................................
........................................
..............................P---|.....
....C-------------|...........|...|.....
....|.............|...........|...|.....
....|.............|...........|...|.....
....|.............|...........|...|.....
....|-------------|...........|...|.....
..............................|---|.....
........................................
........................................
........................................

例 11: 結果をSVGで出力する

SVGはコードで生成できるテキスト形式です。画像の上にボックスとラベルを重ねるのに使い、どのブラウザでも開けます。

// สร้างไฟล์ SVG ซ้อนกรอบบนภาพ เพื่อนำไปแสดงหรือบันทึกผลการตรวจจับ
const dets = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "person", score: 0.88, box: [300, 50, 340, 170] },
];
const lines = ['<svg xmlns="http://www.w3.org/2000/svg" width="400" height="240">'];
lines.push('<rect width="400" height="240" fill="#1b1f2a"/>');
for (const d of dets) {
  const [x1, y1, x2, y2] = d.box;
  lines.push(`<rect x="${x1}" y="${y1}" width="${x2 - x1}" height="${y2 - y1}" fill="none" stroke="#ffb020" stroke-width="2"/>`);
  lines.push(`<text x="${x1}" y="${y1 - 4}" font-size="12" fill="#ffb020">${d.label} ${d.score.toFixed(2)}</text>`);
}
lines.push("</svg>");
const svg = lines.join("\n");
console.log(svg);
console.log("จำนวนบรรทัด:", lines.length, "ความยาว:", svg.length, "ตัวอักษร");

**実際の出力**

<svg xmlns="http://www.w3.org/2000/svg" width="400" height="240">
<rect width="400" height="240" fill="#1b1f2a"/>
<rect x="40" y="60" width="140" height="80" fill="none" stroke="#ffb020" stroke-width="2"/>
<text x="40" y="56" font-size="12" fill="#ffb020">car 0.94</text>
<rect x="300" y="50" width="40" height="120" fill="none" stroke="#ffb020" stroke-width="2"/>
<text x="300" y="46" font-size="12" fill="#ffb020">person 0.88</text>
</svg>
จำนวนบรรทัด: 7 ความยาว: 441 ตัวอักษร

例 12: Precisionと Recall

予測と正解を比べます。TPは正しい検出、FPは誤検出、FNは見逃しです。Precisionは予測のうち正しい割合、Recallは実際の物体のうち見つけた割合です。

// เทียบผลทายกับเฉลย: กรอบที่ IoU >= 0.5 และคลาสตรงกันคือ TP ที่เหลือคือ FP
// เฉลยที่ยังไม่ถูกทายคือ FN
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 gts = [
  { label: "car", box: [40, 60, 180, 140] },
  { label: "person", box: [300, 50, 340, 170] },
  { label: "dog", box: [10, 150, 60, 200] },
];
const preds = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "person", score: 0.88, box: [305, 55, 345, 175] },
  { label: "car", score: 0.40, box: [200, 70, 260, 130] },
];
const used = new Array(gts.length).fill(false);
let tp = 0, fp = 0;
for (const p of [...preds].sort((a, b) => b.score - a.score)) {
  let best = -1, bestIou = 0.5;
  gts.forEach((g, i) => {
    const v = iou(p.box, g.box);
    if (!used[i] && g.label === p.label && v >= bestIou) { bestIou = v; best = i; }
  });
  if (best >= 0) { used[best] = true; tp++; } else fp++;
  console.log(`${p.label.padEnd(7)} ${p.score.toFixed(2)} ${best >= 0 ? "TP" : "FP"}`);
}
const fn = gts.length - tp;
console.log(`TP=${tp} FP=${fp} FN=${fn}`);
console.log("precision =", (tp / (tp + fp)).toFixed(2));
console.log("recall =", (tp / (tp + fn)).toFixed(2));

**実際の出力**

car     0.94 TP
person  0.88 TP
car     0.40 FP
TP=2 FP=1 FN=1
precision = 0.67
recall = 0.67

例 13: Average Precision(AP)

APは信頼度順に並べた precision-recall 曲線の下の面積です。モデルを一つの数値で比べられます。

// Average Precision (AP): สรุปกราฟ precision-recall เป็นตัวเลขเดียว
// เรียงกรอบตามความมั่นใจ แล้วคำนวณ precision และ recall สะสมทีละกรอบ
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 gts = [
  { label: "car", box: [40, 60, 180, 140] },
  { label: "person", box: [300, 50, 340, 170] },
  { label: "dog", box: [10, 150, 60, 200] },
];
const preds = [
  { label: "car", score: 0.94, box: [40, 60, 180, 140] },
  { label: "person", score: 0.88, box: [305, 55, 345, 175] },
  { label: "car", score: 0.40, box: [200, 70, 260, 130] },
  { label: "dog", score: 0.30, box: [12, 152, 62, 202] },
];
const sorted = [...preds].sort((a, b) => b.score - a.score);
const used = new Array(gts.length).fill(false);
const flags = sorted.map(p => {
  let best = -1, bestIou = 0.5;
  gts.forEach((g, i) => {
    const v = iou(p.box, g.box);
    if (!used[i] && g.label === p.label && v >= bestIou) { bestIou = v; best = i; }
  });
  if (best >= 0) { used[best] = true; return 1; }
  return 0;
});
let tp = 0, fp = 0;
const precision = [], recall = [];
flags.forEach(f => {
  if (f) tp++; else fp++;
  precision.push(tp / (tp + fp));
  recall.push(tp / gts.length);
});
for (let i = precision.length - 2; i >= 0; i--) precision[i] = Math.max(precision[i], precision[i + 1]);
let ap = 0, prevRecall = 0;
recall.forEach((r, i) => { ap += (r - prevRecall) * precision[i]; prevRecall = r; });
sorted.forEach((p, i) => console.log(`${p.score.toFixed(2)} ${flags[i] ? "TP" : "FP"} recall=${recall[i].toFixed(2)} precision=${precision[i].toFixed(2)}`));
console.log("AP =", ap.toFixed(3));

**実際の出力**

0.94 TP recall=0.33 precision=1.00
0.88 TP recall=0.67 precision=1.00
0.40 FP recall=0.67 precision=0.75
0.30 TP recall=1.00 precision=0.75
AP = 0.917

例 14: 全体の流れをまとめて実行

生の出力の解読、フィルタリング、NMS、元の画像への座標変換をまとめて実行します。

// ตัวอย่างรวม: ถอดผลดิบ -> กรองด้วยเกณฑ์ -> NMS -> แปลงพิกัดกลับไปยังภาพจริง
const sigmoid = x => 1 / (1 + Math.exp(-x));
const NAMES = ["person", "car", "dog"];
const STRIDE = 100, INPUT = 400;
const ORIG = { w: 1280, h: 720 };
const scale = Math.min(INPUT / ORIG.w, INPUT / ORIG.h);
const padX = (INPUT - ORIG.w * scale) / 2;
const padY = (INPUT - ORIG.h * scale) / 2;
const raw = [
  { row: 1, col: 1, tx: 0.1, ty: -0.2, tw: 0.2, th: 0.0, obj: 2.5, cls: [-1.0, 3.0, 0.2] },
  { row: 1, col: 2, tx: 0.0, ty: 0.1, tw: 0.1, th: 0.2, obj: 2.0, cls: [-0.5, 2.8, 0.0] },
  { row: 2, col: 0, tx: 0.3, ty: 0.0, tw: -0.4, th: -0.2, obj: 1.5, cls: [2.5, -1.0, 0.1] },
  { row: 3, col: 3, tx: -0.5, ty: 0.2, tw: 0.0, th: 0.1, obj: -2.0, cls: [0.0, 0.0, 3.0] },
];
const dets = raw.map(r => {
  const cls = r.cls.map(sigmoid);
  const best = cls.indexOf(Math.max(...cls));
  const cx = (r.col + sigmoid(r.tx)) * STRIDE;
  const cy = (r.row + sigmoid(r.ty)) * STRIDE;
  const w = Math.exp(r.tw) * STRIDE;
  const h = Math.exp(r.th) * STRIDE;
  const score = sigmoid(r.obj) * cls[best];
  return { label: NAMES[best], score, box: [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2] };
});
const kept = dets.filter(d => d.score >= 0.5);
console.log("หลังกรองด้วยเกณฑ์ 0.5:", kept.length, "กรอบ");
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 final = [];
for (const d of [...kept].sort((a, b) => b.score - a.score)) {
  if (final.every(f => f.label !== d.label || iou(f.box, d.box) < 0.5)) final.push(d);
}
console.log("หลัง NMS:", final.length, "กรอบ");
for (const d of final) {
  const [x1, y1, x2, y2] = d.box.map((v, i) => (v - (i % 2 === 0 ? padX : padY)) / scale);
  console.log(`${d.label.padEnd(7)} ${d.score.toFixed(2)} [${[x1, y1, x2, y2].map(v => Math.round(v)).join(", ")}]`);
}

**実際の出力**

หลังกรองด้วยเกณฑ์ 0.5: 3 กรอบ
หลัง NMS: 3 กรอบ
car     0.88 [293, 24, 683, 344]
car     0.83 [623, 13, 977, 403]
person  0.76 [77, 389, 291, 651]

例 15: 動画の物体を数える

動画は連続したフレームです。この例では各フレームのクラスを数え、直近3フレームで最も多いクラスを使ってちらつきを減らします。

// วิดีโอคือภาพต่อกันหลายเฟรม ตรวจทุกเฟรมแล้วนับคลาส
// การดูค่าจากหลายเฟรมที่อยู่ใกล้กันช่วยลดการกระพริบของผลทาย
const frames = [
  ["car", "car", "person"],
  ["car", "person", "person"],
  ["car", "car", "person"],
  ["car", "car"],
  ["person", "car", "car"],
];
frames.forEach((objs, i) => {
  const counts = {};
  for (const o of objs) counts[o] = (counts[o] || 0) + 1;
  console.log(`เฟรม ${i + 1}:`, JSON.stringify(counts));
});
// ใช้คลาสที่ปรากฏบ่อยที่สุดในหน้าต่าง 3 เฟรม
function majority(window) {
  const c = {};
  for (const x of window) c[x] = (c[x] || 0) + 1;
  return Object.entries(c).sort((a, b) => b[1] - a[1])[0][0];
}
const labels = frames.map(f => f[0]);
const smoothed = labels.map((_, i) => majority(labels.slice(Math.max(0, i - 2), i + 1)));
console.log("ป้ายรายเฟรม:", labels.join(" "));
console.log("หลังดูย้อนหลัง 3 เฟรม:", smoothed.join(" "));

**実際の出力**

เฟรม 1: {"car":2,"person":1}
เฟรม 2: {"car":1,"person":2}
เฟรม 3: {"car":2,"person":1}
เฟรม 4: {"car":2}
เฟรม 5: {"person":1,"car":2}
ป้ายรายเฟรม: car car car car person
หลังดูย้อนหลัง 3 เฟรม: car car car car car

次のステップ

実際のモデルを動かすには、Python 3.12をインストールし、pip install ultralytics を実行します。次に YOLO('yolo11n.pt') のように学習済みモデルを読み込み、画像を渡すとボックス、クラス、信頼度が得られます。出力の形式はこの記事の例と同じなので、NMSと座標変換の処理はそのまま使えます。

まとめ

物体検出は、画像内の物体のボックスとクラスを見つけます。主な手順は、グリッドの出力を読み取り、信頼度で絞り込み、NMSを適用し、元の画像に座標を戻すことです。品質は precision、recall、AP で測ります。この記事のすべての出力は、例のコードを実際に実行した結果です。