465 lines
19 KiB
Python
465 lines
19 KiB
Python
"""
|
||
Script 3 — End-to-end Inference (Detect → Classify)
|
||
=====================================================
|
||
1. Runs your YOLOv8n detector on each image / video frame.
|
||
2. Crops every detection.
|
||
3. Feeds each crop through the classification head.
|
||
4. Draws annotated bounding boxes with class names & confidence.
|
||
|
||
Supported sources
|
||
-----------------
|
||
• A single image (--source image.jpg)
|
||
• A folder of images (--source /path/to/imgs)
|
||
• A video file (--source video.mp4)
|
||
• A webcam (--source 0)
|
||
|
||
Usage
|
||
-----
|
||
python 3_inference.py \
|
||
--detector_weights runs/yolo/best.pt \
|
||
--classifier_weights runs/classify/best.pt \
|
||
--source /path/to/images_or_video \
|
||
--det_conf 0.3 \
|
||
--cls_conf 0.5 \
|
||
--img_size 640 \
|
||
--padding 8 \
|
||
--output_dir inference_results \
|
||
--show \
|
||
--save_crops
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import cv2
|
||
import numpy as np
|
||
import torch
|
||
import torch.nn.functional as F
|
||
from torchvision import models, transforms
|
||
from ultralytics import YOLO
|
||
from tqdm import tqdm
|
||
|
||
|
||
# ─────────────────────────── args ────────────────────────────────────────────
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(description="YOLOv8 detect + classify inference")
|
||
p.add_argument("--detector_weights", default="detector/best.pt", help="YOLOv8n .pt (fine-tuned)")
|
||
p.add_argument("--classifier_weights", default="best.pt", help="Classifier best.pt from script 2")
|
||
p.add_argument("--source", required=True, help="Image / folder / video / webcam index")
|
||
p.add_argument("--det_conf", type=float, default=0.30, help="YOLO detection confidence")
|
||
p.add_argument("--det_iou", type=float, default=0.45, help="YOLO NMS IoU")
|
||
p.add_argument("--cls_conf", type=float, default=0.60, help="Min classifier confidence to label")
|
||
p.add_argument("--img_size", type=int, default=640, help="YOLO input size")
|
||
p.add_argument("--padding", type=int, default=8, help="Extra pixels around each crop")
|
||
p.add_argument("--cls_img_size",type=int, default=224, help="Classifier input size")
|
||
p.add_argument("--output_dir", default="inference_results")
|
||
p.add_argument("--show", action="store_true", help="Display results with cv2.imshow")
|
||
p.add_argument("--save_crops", action="store_true", help="Save individual crop images")
|
||
p.add_argument("--no_save", action="store_true", help="Do not save annotated images/video")
|
||
p.add_argument("--batch_cls", type=int, default=16, help="Classifier batch size per frame")
|
||
p.add_argument("--device", default="", help="cuda / cpu / mps (auto-detect if empty)")
|
||
return p.parse_args()
|
||
|
||
|
||
# ─────────────────────────── classifier wrapper ──────────────────────────────
|
||
|
||
# ── Device-agnostic OOM detection ────────────────────────────────────────────
|
||
#
|
||
# Each backend raises a different exception type for out-of-memory:
|
||
#
|
||
# CUDA → torch.cuda.OutOfMemoryError (subclass of RuntimeError)
|
||
# MPS → RuntimeError whose message contains "out of memory"
|
||
# CPU → MemoryError (Python built-in, raised by the OS allocator)
|
||
#
|
||
# We centralise the check here so nothing else needs to know the device type.
|
||
|
||
def _is_oom(exc: BaseException) -> bool:
|
||
"""Return True if *exc* represents an out-of-memory condition."""
|
||
if isinstance(exc, MemoryError): # CPU / OS
|
||
return True
|
||
if torch.cuda.is_available() and isinstance(exc, torch.cuda.OutOfMemoryError):
|
||
return True # CUDA
|
||
if isinstance(exc, RuntimeError): # MPS + fallback CUDA
|
||
msg = str(exc).lower()
|
||
return "out of memory" in msg or "memory" in msg and "alloc" in msg
|
||
return False
|
||
|
||
|
||
def _free_device_cache(device: torch.device) -> None:
|
||
"""Release any cached memory held by the current device allocator."""
|
||
if device.type == "cuda":
|
||
torch.cuda.empty_cache()
|
||
elif device.type == "mps":
|
||
# torch.mps.empty_cache() was added in PyTorch 2.1 — guard for older versions
|
||
if hasattr(torch.mps, "empty_cache"):
|
||
torch.mps.empty_cache()
|
||
# CPU has no cache to release
|
||
|
||
|
||
def _sync_device(device: torch.device) -> None:
|
||
"""Wait for all pending ops on *device* so OOM surfaces immediately."""
|
||
if device.type == "cuda":
|
||
torch.cuda.synchronize()
|
||
elif device.type == "mps":
|
||
if hasattr(torch.mps, "synchronize"):
|
||
torch.mps.synchronize()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ProductClassifier:
|
||
"""Thin wrapper around the saved checkpoint from script 2."""
|
||
|
||
# Supported architectures — mirrors script 2
|
||
_BUILDERS = {
|
||
"efficientnet_b0": (models.efficientnet_b0, lambda m, n: setattr(m.classifier, '1', torch.nn.Linear(m.classifier[1].in_features if hasattr(m.classifier[1], 'in_features') else 1280, n))),
|
||
"efficientnet_b2": (models.efficientnet_b2, lambda m, n: setattr(m.classifier, '1', torch.nn.Linear(m.classifier[1].in_features if hasattr(m.classifier[1], 'in_features') else 1408, n))),
|
||
"mobilenet_v3_small": (models.mobilenet_v3_small, lambda m, n: setattr(m.classifier, '3', torch.nn.Linear(m.classifier[3].in_features if hasattr(m.classifier[3], 'in_features') else 576, n))),
|
||
"resnet50": (models.resnet50, lambda m, n: setattr(m, 'fc', torch.nn.Linear(m.fc.in_features, n))),
|
||
}
|
||
|
||
def __init__(self, weights_path: str, device: torch.device, img_size: int = 224):
|
||
ckpt = torch.load(weights_path, map_location=device)
|
||
self.class_names: list = ckpt["class_names"]
|
||
num_classes = len(self.class_names)
|
||
|
||
model_name = ckpt.get("args", {}).get("model", "efficientnet_b0")
|
||
builder_fn, head_fn = self._BUILDERS[model_name]
|
||
self.model = builder_fn(weights=None)
|
||
head_fn(self.model, num_classes)
|
||
self.model.load_state_dict(ckpt["model_state"])
|
||
self.model.to(device).eval()
|
||
self.device = device
|
||
|
||
mean = [0.485, 0.456, 0.406]
|
||
std = [0.229, 0.224, 0.225]
|
||
self.transform = transforms.Compose([
|
||
transforms.ToPILImage(),
|
||
transforms.Resize(int(img_size * 1.15)),
|
||
transforms.CenterCrop(img_size),
|
||
transforms.ToTensor(),
|
||
transforms.Normalize(mean, std),
|
||
])
|
||
|
||
# Probe VRAM once at startup — stored and reused every frame.
|
||
# Can be overridden at runtime if OOM occurs (see predict_batch).
|
||
self._batch_size: int = self.calibrate_batch_size(img_size)
|
||
|
||
def calibrate_batch_size(self, img_size: int, start: int = 2, max_size: int = 512) -> int:
|
||
"""
|
||
Probe the device at startup to find the largest batch that fits in memory.
|
||
|
||
Works on CUDA, MPS, and CPU:
|
||
• CUDA / MPS — doubles the batch size until OOM, steps back with margin.
|
||
• CPU — RAM is virtually unlimited; returns a sensible fixed cap.
|
||
|
||
Returns the largest safe batch size, always ≥ 1.
|
||
"""
|
||
if self.device.type == "cpu":
|
||
print(" [batch-probe] CPU detected — using fixed batch size 64")
|
||
return 64
|
||
|
||
print(f" [batch-probe] Probing {self.device.type.upper()} memory "
|
||
f"for optimal batch size (img={img_size}px) …", flush=True)
|
||
|
||
dummy = torch.zeros(1, 3, img_size, img_size, device=self.device)
|
||
safe_size = 1
|
||
probe_size = start
|
||
|
||
with torch.no_grad():
|
||
while probe_size <= max_size:
|
||
try:
|
||
batch = dummy.expand(probe_size, -1, -1, -1)
|
||
_ = self.model(batch)
|
||
|
||
# Flush pending async ops so OOM surfaces here, not later
|
||
_sync_device(self.device)
|
||
|
||
safe_size = probe_size
|
||
probe_size = probe_size * 2
|
||
|
||
except Exception as exc:
|
||
if _is_oom(exc):
|
||
break # safe_size is our answer
|
||
raise # unexpected error — propagate
|
||
|
||
finally:
|
||
_free_device_cache(self.device)
|
||
|
||
# 20 % headroom margin: real crops vary in size unlike the uniform dummy
|
||
safe_size = max(1, int(safe_size * 0.8))
|
||
print(f" [batch-probe] Settled on batch_size = {safe_size} "
|
||
f"(probe ceiling was {probe_size // 2})")
|
||
return safe_size
|
||
|
||
@torch.no_grad()
|
||
def predict_batch(self, crops_bgr: list, batch_size: int | None = None) -> list[tuple[str, float]]:
|
||
"""
|
||
Classifies an arbitrary list of crops in memory-safe chunks.
|
||
|
||
crops_bgr : list of H×W×3 uint8 BGR numpy arrays
|
||
batch_size : chunk size to use. Pass None to use self._batch_size
|
||
(set by calibrate_batch_size at startup).
|
||
Returns : list of (class_name, confidence) — same order as input
|
||
"""
|
||
if not crops_bgr:
|
||
return []
|
||
|
||
# Resolve which batch size to use
|
||
chunk_size = batch_size if batch_size is not None else self._batch_size
|
||
|
||
# Pre-process all crops on CPU (fast; no GPU memory involved yet)
|
||
tensors = [
|
||
self.transform(cv2.cvtColor(crop, cv2.COLOR_BGR2RGB))
|
||
for crop in crops_bgr
|
||
]
|
||
|
||
results: list[tuple[str, float]] = []
|
||
|
||
i = 0
|
||
while i < len(tensors):
|
||
chunk = tensors[i : i + chunk_size]
|
||
batch = torch.stack(chunk).to(self.device)
|
||
|
||
try:
|
||
logits = self.model(batch)
|
||
probs = F.softmax(logits, dim=1)
|
||
confs, idxs = probs.max(dim=1)
|
||
|
||
for idx, conf in zip(idxs, confs):
|
||
results.append((self.class_names[idx.item()], conf.item()))
|
||
|
||
i += chunk_size # advance only on success
|
||
|
||
except Exception as exc:
|
||
if not _is_oom(exc):
|
||
raise # unexpected error — don't swallow it
|
||
|
||
# ── Runtime OOM safety net ────────────────────────────────
|
||
# The probe uses a uniform dummy; real crops vary and can
|
||
# occasionally exceed the probed limit. Halve and retry
|
||
# WITHOUT advancing i so the same chunk is retried.
|
||
_free_device_cache(self.device)
|
||
|
||
new_size = max(1, chunk_size // 2)
|
||
print(f" [batch] Runtime OOM on {self.device.type.upper()} "
|
||
f"— halving chunk {chunk_size} → {new_size}")
|
||
chunk_size = new_size
|
||
self._batch_size = new_size # persist for future frames
|
||
|
||
finally:
|
||
del batch # free GPU memory immediately after each chunk
|
||
|
||
return results
|
||
|
||
|
||
# ─────────────────────────── drawing ─────────────────────────────────────────
|
||
|
||
# One colour per class, generated on first use
|
||
_COLOUR_CACHE: dict[str, tuple] = {}
|
||
|
||
|
||
def class_colour(name: str) -> tuple:
|
||
if name not in _COLOUR_CACHE:
|
||
# OpenCV HSV: hue is 0–179 (NOT 0–360) — values above 179 overflow uint8
|
||
h = hash(name) % 180
|
||
col = cv2.cvtColor(
|
||
np.array([[[h, 220, 200]]], dtype=np.uint8), cv2.COLOR_HSV2BGR
|
||
)[0][0]
|
||
_COLOUR_CACHE[name] = tuple(int(c) for c in col)
|
||
return _COLOUR_CACHE[name]
|
||
|
||
|
||
def draw_result(frame, box_xyxy, label: str, conf: float, cls_conf_thresh: float):
|
||
x1, y1, x2, y2 = map(int, box_xyxy)
|
||
colour = class_colour(label)
|
||
cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2)
|
||
|
||
if conf >= cls_conf_thresh:
|
||
text = f"{label} {conf:.2f}"
|
||
else:
|
||
text = "?"
|
||
|
||
(tw, th), bl = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1)
|
||
cv2.rectangle(frame, (x1, y1 - th - bl - 4), (x1 + tw + 4, y1), colour, -1)
|
||
cv2.putText(frame, text, (x1 + 2, y1 - bl - 2),
|
||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
|
||
return frame
|
||
|
||
|
||
# ─────────────────────────── source helpers ──────────────────────────────────
|
||
|
||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"}
|
||
|
||
|
||
def is_image(path: Path) -> bool:
|
||
return path.suffix.lower() in IMAGE_EXTS
|
||
|
||
|
||
def is_video(path: Path) -> bool:
|
||
return path.suffix.lower() in {".mp4", ".avi", ".mov", ".mkv", ".webm"}
|
||
|
||
|
||
def iter_source(source: str):
|
||
"""
|
||
Yields (frame_bgr, frame_id, source_name) for every frame / image.
|
||
"""
|
||
try:
|
||
cam_idx = int(source) # webcam
|
||
cap = cv2.VideoCapture(cam_idx)
|
||
fid = 0
|
||
while True:
|
||
ret, frame = cap.read()
|
||
if not ret:
|
||
break
|
||
yield frame, fid, f"webcam_{cam_idx}"
|
||
fid += 1
|
||
cap.release()
|
||
return
|
||
except ValueError:
|
||
pass
|
||
|
||
p = Path(source)
|
||
if p.is_file() and is_image(p):
|
||
img = cv2.imread(str(p))
|
||
if img is not None:
|
||
yield img, 0, p.stem
|
||
elif p.is_file() and is_video(p):
|
||
cap = cv2.VideoCapture(str(p))
|
||
fid = 0
|
||
while True:
|
||
ret, frame = cap.read()
|
||
if not ret:
|
||
break
|
||
yield frame, fid, p.stem
|
||
fid += 1
|
||
cap.release()
|
||
elif p.is_dir():
|
||
img_paths = sorted(q for q in p.rglob("*") if is_image(q))
|
||
for q in tqdm(img_paths, desc="Images"):
|
||
img = cv2.imread(str(q))
|
||
if img is not None:
|
||
yield img, 0, q.stem
|
||
else:
|
||
raise FileNotFoundError(f"Cannot open source: {source}")
|
||
|
||
|
||
# ─────────────────────────── main ────────────────────────────────────────────
|
||
|
||
def main():
|
||
args = parse_args()
|
||
|
||
# Device
|
||
if args.device:
|
||
device = torch.device(args.device)
|
||
elif torch.cuda.is_available():
|
||
device = torch.device("cuda")
|
||
elif torch.backends.mps.is_available():
|
||
device = torch.device("mps")
|
||
else:
|
||
device = torch.device("cpu")
|
||
print(f"Device : {device}")
|
||
|
||
# Load models
|
||
print(f"Loading detector : {args.detector_weights}")
|
||
detector = YOLO(args.detector_weights)
|
||
|
||
print(f"Loading classifier: {args.classifier_weights}")
|
||
classifier = ProductClassifier(args.classifier_weights, device, args.cls_img_size)
|
||
print(f" Classes ({len(classifier.class_names)}): {classifier.class_names}")
|
||
|
||
# Output directory
|
||
out_dir = Path(args.output_dir)
|
||
if not args.no_save:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
crops_dir = out_dir / "crops" if args.save_crops else None
|
||
if crops_dir:
|
||
crops_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Stats
|
||
stats = {"frames": 0, "detections": 0, "classified": 0, "fps": []}
|
||
|
||
for frame, frame_id, stem in iter_source(args.source):
|
||
t0 = time.perf_counter()
|
||
|
||
# ── Detection ──────────────────────────────────────────────────────
|
||
results = detector(
|
||
frame,
|
||
conf=args.det_conf,
|
||
iou=args.det_iou,
|
||
imgsz=args.img_size,
|
||
verbose=False,
|
||
)[0]
|
||
|
||
h, w = frame.shape[:2]
|
||
crops, boxes = [], []
|
||
|
||
for box in results.boxes:
|
||
x1, y1, x2, y2 = map(int, box.xyxy[0].tolist())
|
||
x1 = max(0, x1 - args.padding); y1 = max(0, y1 - args.padding)
|
||
x2 = min(w, x2 + args.padding); y2 = min(h, y2 + args.padding)
|
||
crop = frame[y1:y2, x1:x2]
|
||
if crop.size == 0:
|
||
continue
|
||
crops.append(crop)
|
||
boxes.append((x1, y1, x2, y2))
|
||
|
||
# ── Classification (dynamic chunked batches) ───────────────────────
|
||
# Batch size is auto-calibrated to VRAM at startup via probe,
|
||
# and auto-reduces at runtime if OOM occurs (e.g. unusually dense frame).
|
||
predictions = classifier.predict_batch(crops)
|
||
|
||
# ── Draw & annotate ───────────────────────────────────────────────
|
||
annotated = frame.copy()
|
||
for (x1, y1, x2, y2), (cls_name, cls_conf) in zip(boxes, predictions):
|
||
draw_result(annotated, (x1, y1, x2, y2), cls_name, cls_conf, args.cls_conf)
|
||
if cls_conf >= args.cls_conf:
|
||
stats["classified"] += 1
|
||
|
||
# FPS overlay
|
||
elapsed = time.perf_counter() - t0
|
||
fps = 1.0 / elapsed if elapsed > 0 else 0
|
||
stats["fps"].append(fps)
|
||
cv2.putText(annotated, f"FPS: {fps:.1f} Det: {len(boxes)}",
|
||
(10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
|
||
|
||
# ── Save / display ────────────────────────────────────────────────
|
||
if not args.no_save:
|
||
out_path = out_dir / f"{stem}_frame{frame_id:06d}.jpg"
|
||
cv2.imwrite(str(out_path), annotated)
|
||
|
||
if args.save_crops and crops_dir:
|
||
for i, (crop, (cls_name, cls_conf)) in enumerate(zip(crops, predictions)):
|
||
tag = cls_name if cls_conf >= args.cls_conf else "uncertain"
|
||
cv2.imwrite(str(crops_dir / f"{stem}_f{frame_id:06d}_c{i:04d}_{tag}.jpg"), crop)
|
||
|
||
if args.show:
|
||
cv2.imshow("Detect + Classify", annotated)
|
||
key = cv2.waitKey(1)
|
||
if key in (ord("q"), 27):
|
||
break
|
||
|
||
stats["frames"] += 1
|
||
stats["detections"] += len(boxes)
|
||
|
||
cv2.destroyAllWindows()
|
||
|
||
# ── Summary ───────────────────────────────────────────────────────────
|
||
avg_fps = np.mean(stats["fps"]) if stats["fps"] else 0
|
||
print("\n" + "═" * 55)
|
||
print(" Inference summary")
|
||
print(f" Frames processed : {stats['frames']}")
|
||
print(f" Total detections : {stats['detections']}")
|
||
print(f" Classified (≥{args.cls_conf:.0%}): {stats['classified']}")
|
||
print(f" Avg FPS : {avg_fps:.1f}")
|
||
if not args.no_save:
|
||
print(f" Results saved to : {out_dir.resolve()}")
|
||
print("═" * 55)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|