feat(deploy): 不依赖 ultralytics 的 OpenVINO 推理脚本
只用 openvino + numpy + PIL,可以直接拷到部署设备上跑(连同 engine/tiling.py 与 engine/detection.py 三个文件)。 自己解码原始输出:前 4 行是像素 xywh、可能越界要裁剪,其余每行一个类别的分数且已过 sigmoid;导出的 metadata.yaml 里 end2end=false,必须自己做 NMS——实测同一个目标会出 3 个候选框。灰度图自动补成三通道。 图片已经是模型输入尺寸时直接推理;大于输入尺寸时加 --tile-size 自行切片再拼回。 给了 --labels 还会统计逐类 TP/FP/FN。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ddd77653b5
commit
ada67466d4
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python
|
||||
"""用导出的 OpenVINO 模型推理,不依赖 ultralytics 与 torch。
|
||||
|
||||
# 已经切好的 160 片:直接推理
|
||||
uv run python scripts/openvino_predict.py \\
|
||||
--model runs/detect/current_openvino_model \\
|
||||
--images data/film_yolo_160/images/val --out /tmp/ov_pred
|
||||
|
||||
# 任意尺寸的原图:加 --tile-size,脚本自己切片再拼回
|
||||
uv run python scripts/openvino_predict.py \\
|
||||
--model runs/detect/current_openvino_model \\
|
||||
--images runs/boxes_shap_label_studio/images --tile-size 160 --out /tmp/ov_pred
|
||||
|
||||
产出 YOLO 格式的标签(`class cx cy w h conf`,坐标归一化),`--labels` 指向真值目录时
|
||||
还会给出逐类的 TP/FP/FN。
|
||||
|
||||
**拷到别的设备上跑,需要这三个文件**:本脚本、`engine/tiling.py`、`engine/detection.py`
|
||||
(后两者只依赖 numpy 与标准库),外加 `pip install openvino numpy pillow`。
|
||||
|
||||
推理契约(实测确认):
|
||||
|
||||
- 输入 `[1, 3, N, N]` float32,**RGB** 顺序,除以 255,无 mean/std 标准化;
|
||||
- 输出 `[1, 4 + 类别数, anchor 数]`:前 4 行是 **xywh 中心点、像素单位**(可能超出边界,
|
||||
要裁剪),后面每行是一个类别的分数,已过 sigmoid;
|
||||
- 导出的 `metadata.yaml` 里 `end2end: false`,**必须自己做 NMS**——实测同一个目标会出
|
||||
3 个候选框。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from engine.detection import Box, match_boxes, nms, parse_label # noqa: E402
|
||||
from engine.tiling import cut, merge_tiles, plan_tiles # noqa: E402
|
||||
from utils import setup_logging # noqa: E402
|
||||
|
||||
logger = None
|
||||
|
||||
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
|
||||
|
||||
|
||||
class OpenVinoDetector:
|
||||
"""加载导出目录里的 .xml/.bin 与 metadata.yaml。"""
|
||||
|
||||
def __init__(self, model_dir: Path, device: str = "CPU") -> None:
|
||||
import openvino as ov
|
||||
import yaml
|
||||
|
||||
xml = next(model_dir.glob("*.xml"), None)
|
||||
if xml is None:
|
||||
raise FileNotFoundError(f"{model_dir} 下没有 .xml,这不像 OpenVINO 的导出目录")
|
||||
core = ov.Core()
|
||||
self.compiled = core.compile_model(core.read_model(str(xml)), device)
|
||||
self.output = self.compiled.output(0)
|
||||
|
||||
meta_file = model_dir / "metadata.yaml"
|
||||
meta = yaml.safe_load(meta_file.read_text(encoding="utf-8")) if meta_file.exists() else {}
|
||||
names = meta.get("names") or {}
|
||||
self.classes = [names[i] for i in sorted(names)] if names else []
|
||||
imgsz = meta.get("imgsz") or []
|
||||
# 以网络自身的输入形状为准,metadata 只是参考
|
||||
shape = self.compiled.input(0).get_partial_shape()
|
||||
self.size = int(shape[2].get_length()) if shape[2].is_static else (imgsz[0] if imgsz else 0)
|
||||
|
||||
def __call__(self, patches, conf: float, iou: float) -> list[list[Box]]:
|
||||
"""一批 size×size 的图 -> 每张图的归一化框(已做 NMS)。"""
|
||||
out = []
|
||||
for patch in patches:
|
||||
raw = self.compiled([self._preprocess(patch)])[self.output][0] # (4+nc, anchors)
|
||||
out.append(self._decode(raw, conf, iou))
|
||||
return out
|
||||
|
||||
def _preprocess(self, patch: np.ndarray) -> np.ndarray:
|
||||
"""HWC uint8 -> BCHW float32 RGB,除以 255。灰度图补成三通道。"""
|
||||
if patch.ndim == 2:
|
||||
patch = np.repeat(patch[..., None], 3, axis=2)
|
||||
elif patch.shape[2] == 1:
|
||||
patch = np.repeat(patch, 3, axis=2)
|
||||
elif patch.shape[2] == 4:
|
||||
patch = patch[..., :3]
|
||||
return patch.astype(np.float32).transpose(2, 0, 1)[None] / 255.0
|
||||
|
||||
def _decode(self, raw: np.ndarray, conf: float, iou: float) -> list[Box]:
|
||||
"""原始输出 -> 归一化 xyxy 框。坐标是像素 xywh,分数已过 sigmoid。"""
|
||||
xywh, scores = raw[:4], raw[4:]
|
||||
class_ids, confidences = scores.argmax(0), scores.max(0)
|
||||
keep = confidences >= conf
|
||||
if not keep.any():
|
||||
return []
|
||||
cx, cy, w, h = xywh[:, keep]
|
||||
size = self.size
|
||||
boxes = [
|
||||
Box(int(class_id),
|
||||
float(np.clip((x - width / 2) / size, 0.0, 1.0)),
|
||||
float(np.clip((y - height / 2) / size, 0.0, 1.0)),
|
||||
float(np.clip((x + width / 2) / size, 0.0, 1.0)),
|
||||
float(np.clip((y + height / 2) / size, 0.0, 1.0)),
|
||||
float(score))
|
||||
for x, y, width, height, class_id, score in zip(
|
||||
cx, cy, w, h, class_ids[keep], confidences[keep], strict=True,
|
||||
)
|
||||
]
|
||||
return [boxes[i] for i in nms(boxes, iou)]
|
||||
|
||||
|
||||
def load_image(path: Path) -> np.ndarray:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as handle:
|
||||
image = handle.convert("L") if handle.mode in ("L", "1", "I;16") else handle.convert("RGB")
|
||||
return np.asarray(image)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global logger
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--model", required=True, help="OpenVINO 导出目录(含 .xml/.bin)")
|
||||
parser.add_argument("--images", required=True, help="图片目录")
|
||||
parser.add_argument("--out", default=None, help="把预测写成 YOLO 标签的目录")
|
||||
parser.add_argument("--labels", default=None, help="真值标签目录,给了就统计 TP/FP/FN")
|
||||
parser.add_argument("--device", default="CPU", help="CPU / GPU / AUTO")
|
||||
parser.add_argument("--conf", type=float, default=0.25)
|
||||
parser.add_argument("--iou", type=float, default=0.5, help="NMS 的 IoU 阈值")
|
||||
parser.add_argument(
|
||||
"--tile-size", type=int, default=0,
|
||||
help="图片大于模型输入时需要切片;图片本身就是模型输入尺寸时留 0",
|
||||
)
|
||||
parser.add_argument("--tile-overlap", type=int, default=32)
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
logger = setup_logging()
|
||||
images = sorted(
|
||||
p for p in Path(args.images).iterdir() if p.suffix.lower() in IMAGE_SUFFIXES
|
||||
)
|
||||
if args.limit:
|
||||
images = images[: args.limit]
|
||||
if not images:
|
||||
logger.error("%s 下没有图片", args.images)
|
||||
return 1
|
||||
|
||||
try:
|
||||
detector = OpenVinoDetector(Path(args.model), args.device)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
logger.info(
|
||||
"模型 %s(输入 %d,%d 类)| 设备 %s | 图片 %d 张",
|
||||
args.model, detector.size, len(detector.classes), args.device, len(images),
|
||||
)
|
||||
|
||||
out_dir = Path(args.out) if args.out else None
|
||||
if out_dir:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
labels_dir = Path(args.labels) if args.labels else None
|
||||
|
||||
per_class: Counter[str] = Counter()
|
||||
stats = {"tp": Counter(), "fp": Counter(), "fn": Counter()}
|
||||
with_boxes = 0
|
||||
mismatched = 0
|
||||
|
||||
for index, path in enumerate(images, start=1):
|
||||
image = load_image(path)
|
||||
height, width = image.shape[:2]
|
||||
|
||||
if args.tile_size > 0:
|
||||
tiles = plan_tiles(width, height, args.tile_size, args.tile_overlap)
|
||||
patches = [cut(image, tile, args.tile_size) for tile in tiles]
|
||||
boxes = merge_tiles(
|
||||
detector(patches, args.conf, args.iou), tiles, args.tile_size, width, height
|
||||
)
|
||||
else:
|
||||
if (height, width) != (detector.size, detector.size):
|
||||
mismatched += 1
|
||||
continue
|
||||
boxes = detector([image], args.conf, args.iou)[0]
|
||||
|
||||
with_boxes += bool(boxes)
|
||||
for box in boxes:
|
||||
per_class[detector.classes[box.class_id] if detector.classes else box.class_id] += 1
|
||||
|
||||
if out_dir:
|
||||
lines = [
|
||||
f"{b.class_id} {(b.x1 + b.x2) / 2:.6f} {(b.y1 + b.y2) / 2:.6f} "
|
||||
f"{b.x2 - b.x1:.6f} {b.y2 - b.y1:.6f} {b.score:.4f}"
|
||||
for b in boxes
|
||||
]
|
||||
(out_dir / f"{path.stem}.txt").write_text(
|
||||
"".join(f"{line}\n" for line in lines), encoding="utf-8"
|
||||
)
|
||||
|
||||
if labels_dir:
|
||||
truth_file = labels_dir / f"{path.stem}.txt"
|
||||
truth = parse_label(truth_file.read_text(encoding="utf-8")) if truth_file.exists() else []
|
||||
match = match_boxes(boxes, truth, iou_threshold=0.5)
|
||||
name = lambda i: detector.classes[i] if detector.classes else i # noqa: E731
|
||||
for _, gt_index in match.pairs:
|
||||
stats["tp"][name(truth[gt_index].class_id)] += 1
|
||||
for i in match.unmatched_predictions:
|
||||
stats["fp"][name(boxes[i].class_id)] += 1
|
||||
for i in match.unmatched_ground_truth:
|
||||
stats["fn"][name(truth[i].class_id)] += 1
|
||||
|
||||
if index % 2000 == 0:
|
||||
logger.info("处理 [%d/%d]", index, len(images))
|
||||
|
||||
if mismatched:
|
||||
logger.warning(
|
||||
"%d 张图不是 %dx%d,已跳过——它们需要切片,加 --tile-size %d",
|
||||
mismatched, detector.size, detector.size, detector.size,
|
||||
)
|
||||
logger.info(
|
||||
"完成:%d 张,有框 %d,框数 %d", len(images) - mismatched, with_boxes, sum(per_class.values())
|
||||
)
|
||||
logger.info("逐类框数: %s", dict(sorted(per_class.items(), key=lambda kv: -kv[1])))
|
||||
|
||||
if labels_dir:
|
||||
logger.info("%-12s %8s %8s %8s %8s", "类别", "TP", "FP", "FN", "召回")
|
||||
for name in detector.classes:
|
||||
tp, fp, fn = (stats[k][name] for k in ("tp", "fp", "fn"))
|
||||
if tp or fp or fn:
|
||||
logger.info(
|
||||
"%-12s %8d %8d %8d %7.1f%%", name, tp, fp, fn, 100 * tp / max(tp + fn, 1)
|
||||
)
|
||||
if out_dir:
|
||||
(out_dir / "summary.json").write_text(
|
||||
json.dumps(
|
||||
{k: dict(v) for k, v in stats.items()}, ensure_ascii=False, indent=2
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user