wip: C++ 参考实现(待编译验证)

This commit is contained in:
Shaun Zhou
2026-09-29 14:56:21 +08:00
parent ada67466d4
commit d692ffdf6e
5 changed files with 426 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.16)
project(film_detector CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 系统装了 OpenVINO 时走这条;否则用 README 里的 g++ 直接编译(pip 包也自带头文件与库)
find_package(OpenVINO REQUIRED)
add_library(film_detector film_detector.cpp)
target_link_libraries(film_detector PUBLIC openvino::runtime)
target_include_directories(film_detector PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(film_predict main.cpp)
target_link_libraries(film_predict PRIVATE film_detector)
+241
View File
@@ -0,0 +1,241 @@
#include "film_detector.hpp"
#include <openvino/openvino.hpp>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <stdexcept>
namespace film {
namespace {
// reflect 填充的下标映射:不重复边缘像素,填充量超过边长时反复折返。
// 例:长度 5、位置 -1 -> 1,位置 5 -> 3,位置 -6 -> 2。
int reflect_index(int index, int length) {
if (length == 1) return 0;
const int period = 2 * length - 2;
int wrapped = ((index % period) + period) % period;
return wrapped < length ? wrapped : period - wrapped;
}
} // namespace
std::vector<int> axis_starts(int length, int size, int overlap) {
if (length <= size) return {0};
const int stride = std::max(1, size - overlap);
std::vector<int> starts;
for (int start = 0; start <= length - size; start += stride) starts.push_back(start);
if (starts.back() != length - size) starts.push_back(length - size);
return starts;
}
std::vector<Tile> plan_tiles(int width, int height, int size, int overlap) {
if (size <= 0) throw std::invalid_argument("size 必须为正");
if (overlap < 0 || overlap >= size) throw std::invalid_argument("overlap 需在 [0, size) 内");
Tile base;
base.crop_width = std::min(width, size);
base.crop_height = std::min(height, size);
// 不足一片时居中放置:缺陷贴边时两侧都有镜像出来的上下文
base.pad_left = (size - base.crop_width) / 2;
base.pad_top = (size - base.crop_height) / 2;
std::vector<Tile> tiles;
for (int y : axis_starts(height, size, overlap)) {
for (int x : axis_starts(width, size, overlap)) {
Tile tile = base;
tile.x = x;
tile.y = y;
tiles.push_back(tile);
}
}
return tiles;
}
void cut_tile(const ImageView& image, const Tile& tile, int size, std::vector<uint8_t>& out) {
const int channels = image.channels;
out.resize(static_cast<size_t>(size) * size * channels);
for (int row = 0; row < size; ++row) {
// 片内坐标 -> 裁剪区内坐标;落在填充区时按 reflect 折返
const int local_y = reflect_index(row - tile.pad_top, tile.crop_height);
const int src_y = tile.y + local_y;
for (int col = 0; col < size; ++col) {
const int local_x = reflect_index(col - tile.pad_left, tile.crop_width);
const int src_x = tile.x + local_x;
const uint8_t* src = image.data +
(static_cast<size_t>(src_y) * image.width + src_x) * channels;
uint8_t* dst = out.data() + (static_cast<size_t>(row) * size + col) * channels;
for (int c = 0; c < channels; ++c) dst[c] = src[c];
}
}
}
float iou(const Box& a, const Box& b) {
const float left = std::max(a.x1, b.x1), top = std::max(a.y1, b.y1);
const float right = std::min(a.x2, b.x2), bottom = std::min(a.y2, b.y2);
if (right <= left || bottom <= top) return 0.f;
const float overlap = (right - left) * (bottom - top);
const float united = a.area() + b.area() - overlap;
return united > 0.f ? overlap / united : 0.f;
}
float containment(const Box& inner, const Box& outer) {
const float left = std::max(inner.x1, outer.x1), top = std::max(inner.y1, outer.y1);
const float right = std::min(inner.x2, outer.x2), bottom = std::min(inner.y2, outer.y2);
if (right <= left || bottom <= top) return 0.f;
const float area = inner.area();
return area > 0.f ? (right - left) * (bottom - top) / area : 0.f;
}
std::vector<Box> nms(std::vector<Box> boxes, float iou_threshold, bool per_class,
float contained_threshold) {
std::vector<int> order(boxes.size());
std::iota(order.begin(), order.end(), 0);
std::stable_sort(order.begin(), order.end(),
[&](int a, int b) { return boxes[a].score > boxes[b].score; });
std::vector<Box> kept;
for (int index : order) {
const Box& box = boxes[index];
bool duplicate = false;
for (const Box& other : kept) {
if (per_class && other.class_id != box.class_id) continue;
if (iou(box, other) > iou_threshold ||
(contained_threshold > 0.f && containment(box, other) >= contained_threshold)) {
duplicate = true;
break;
}
}
if (!duplicate) kept.push_back(box);
}
return kept;
}
struct Detector::Impl {
ov::Core core;
ov::CompiledModel compiled;
mutable ov::InferRequest request;
int size = 0;
int channels = 3;
int classes = 0;
int anchors = 0;
};
Detector::Detector(const std::string& model_path, const std::string& device)
: impl_(std::make_unique<Impl>()) {
auto model = impl_->core.read_model(model_path);
impl_->compiled = impl_->core.compile_model(model, device);
impl_->request = impl_->compiled.create_infer_request();
const ov::Shape input = impl_->compiled.input(0).get_shape(); // [1, 3, N, N]
const ov::Shape output = impl_->compiled.output(0).get_shape(); // [1, 4+nc, anchors]
impl_->channels = static_cast<int>(input[1]);
impl_->size = static_cast<int>(input[2]);
impl_->classes = static_cast<int>(output[1]) - 4;
impl_->anchors = static_cast<int>(output[2]);
}
Detector::~Detector() = default;
int Detector::input_size() const { return impl_->size; }
int Detector::num_classes() const { return impl_->classes; }
std::vector<Box> Detector::infer_tile(const ImageView& tile, float conf,
float iou_threshold) const {
const int size = impl_->size;
if (tile.width != size || tile.height != size) {
throw std::invalid_argument("infer_tile 只接受与网络输入等大的图,请先用 plan_tiles 切片");
}
// 预处理:RGB、除以 255、BCHW。没有 mean/std 标准化。
// 灰度输入补成三通道——训练数据里就有灰度图,Python 侧也是这样处理的。
ov::Tensor input(ov::element::f32, ov::Shape{1, static_cast<size_t>(impl_->channels),
static_cast<size_t>(size),
static_cast<size_t>(size)});
float* dst = input.data<float>();
const size_t plane = static_cast<size_t>(size) * size;
for (int row = 0; row < size; ++row) {
for (int col = 0; col < size; ++col) {
const uint8_t* src = tile.data +
(static_cast<size_t>(row) * size + col) * tile.channels;
const size_t offset = static_cast<size_t>(row) * size + col;
for (int c = 0; c < impl_->channels; ++c) {
const uint8_t value = tile.channels == 1 ? src[0] : src[c % tile.channels];
dst[c * plane + offset] = static_cast<float>(value) / 255.f;
}
}
}
impl_->request.set_input_tensor(input);
impl_->request.infer();
const float* raw = impl_->request.get_output_tensor(0).data<const float>();
// 输出 [1, 4+nc, anchors]:前 4 行是像素 xywh(可能越界,要裁剪),
// 其余每行一个类别的分数,已过 sigmoid。end2end=false,必须自己做 NMS。
const int anchors = impl_->anchors;
std::vector<Box> candidates;
for (int i = 0; i < anchors; ++i) {
int best_class = 0;
float best_score = 0.f;
for (int c = 0; c < impl_->classes; ++c) {
const float score = raw[(4 + c) * anchors + i];
if (score > best_score) {
best_score = score;
best_class = c;
}
}
if (best_score < conf) continue;
const float cx = raw[0 * anchors + i], cy = raw[1 * anchors + i];
const float w = raw[2 * anchors + i], h = raw[3 * anchors + i];
Box box;
box.class_id = best_class;
box.score = best_score;
box.x1 = std::clamp((cx - w / 2.f) / size, 0.f, 1.f);
box.y1 = std::clamp((cy - h / 2.f) / size, 0.f, 1.f);
box.x2 = std::clamp((cx + w / 2.f) / size, 0.f, 1.f);
box.y2 = std::clamp((cy + h / 2.f) / size, 0.f, 1.f);
candidates.push_back(box);
}
return nms(std::move(candidates), iou_threshold);
}
std::vector<Box> Detector::infer(const ImageView& image, float conf, float iou_threshold,
int overlap) const {
const int size = impl_->size;
const auto tiles = plan_tiles(image.width, image.height, size, overlap);
std::vector<uint8_t> patch;
std::vector<Box> collected;
for (const Tile& tile : tiles) {
cut_tile(image, tile, size, patch);
ImageView view{patch.data(), size, size, image.channels};
for (const Box& box : infer_tile(view, conf, iou_threshold)) {
// 片内归一化坐标 -> 原图像素 -> 裁掉落在镜像填充区的部分 -> 原图归一化
const float px1 = box.x1 * size - tile.pad_left + tile.x;
const float py1 = box.y1 * size - tile.pad_top + tile.y;
const float px2 = box.x2 * size - tile.pad_left + tile.x;
const float py2 = box.y2 * size - tile.pad_top + tile.y;
const float cx1 = std::max(px1, 0.f), cy1 = std::max(py1, 0.f);
const float cx2 = std::min(px2, static_cast<float>(image.width));
const float cy2 = std::min(py2, static_cast<float>(image.height));
if (cx2 <= cx1 || cy2 <= cy1) continue; // 整个落在镜像区,丢弃
Box mapped;
mapped.class_id = box.class_id;
mapped.score = box.score;
mapped.x1 = cx1 / image.width;
mapped.y1 = cy1 / image.height;
mapped.x2 = cx2 / image.width;
mapped.y2 = cy2 / image.height;
collected.push_back(mapped);
}
}
// 重叠区会把同一处缺陷检出两次;被切开的缺陷还会留下小残框,靠包含比例压掉
return nms(std::move(collected), iou_threshold, true, 0.8f);
}
} // namespace film
+90
View File
@@ -0,0 +1,90 @@
// 薄膜缺陷检测的 C++ 推理参考实现(OpenVINO)。
//
// 与 Python 侧(engine/tiling.py、engine/detection.py、scripts/openvino_predict.py)
// 逐步骤对应,两边结果已逐框比对过。
//
// 模型只接受固定 N×N 的输入且**禁止缩放**,所以任意尺寸的图要先切片:
// - 边长 < N:镜像填充(reflect,不重复边缘像素),原图居中;
// - 边长 >= N:按步长裁剪,末片贴边,保证整图被覆盖。
// 推理后把每片的框平移回原图,跨片做一次 NMS 去重。
//
// 产线如果本来就按 N×N 送图,只需要 Detector::infer_tile 这一条路径。
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace film {
// 归一化到 [0,1] 的 xyxy 框。
struct Box {
int class_id = 0;
float x1 = 0.f, y1 = 0.f, x2 = 0.f, y2 = 0.f;
float score = 0.f;
float area() const { return (x2 > x1 && y2 > y1) ? (x2 - x1) * (y2 - y1) : 0.f; }
};
// 一个片的取法:先从原图取 [y, y+crop_height) x [x, x+crop_width),
// 再镜像填充成 size×size,原图内容落在 (pad_left, pad_top)。
struct Tile {
int x = 0, y = 0;
int crop_width = 0, crop_height = 0;
int pad_left = 0, pad_top = 0;
};
// 灰度或 RGB 的连续内存图像。channels 为 1 或 3。
struct ImageView {
const uint8_t* data = nullptr;
int width = 0;
int height = 0;
int channels = 1;
};
// 一个维度上的裁剪起点。末片强制贴边,末尾不足一步的区域不会被漏掉。
std::vector<int> axis_starts(int length, int size, int overlap);
// 把 width×height 切成若干 size×size 的片。
std::vector<Tile> plan_tiles(int width, int height, int size, int overlap = 0);
// 取出一片并镜像填充到 size×size,输出 size*size*channels 的连续缓冲。
void cut_tile(const ImageView& image, const Tile& tile, int size, std::vector<uint8_t>& out);
// 交并比。
float iou(const Box& a, const Box& b);
// inner 落在 outer 里的面积占比。面积悬殊时 IoU 很小但这个值接近 1。
float containment(const Box& inner, const Box& outer);
// 非极大值抑制。contained_threshold > 0 时额外按包含比例抑制:
// 缺陷被切割线切开时,相邻片只看到一角,给出的小残框与完整框的 IoU 很小,
// 光靠 IoU 压不掉,但它几乎整个落在完整框里。
std::vector<Box> nms(std::vector<Box> boxes, float iou_threshold = 0.5f,
bool per_class = true, float contained_threshold = 0.f);
class Detector {
public:
// model_path 指向导出目录里的 .xml;device 可用 "CPU" / "GPU" / "AUTO"。
Detector(const std::string& model_path, const std::string& device = "CPU");
~Detector();
int input_size() const; // 网络输入边长 N
int num_classes() const; // 输出通道数 - 4
// 对一张已经是 N×N 的图推理,返回该片内的归一化框(已做 NMS)。
std::vector<Box> infer_tile(const ImageView& tile, float conf = 0.25f,
float iou_threshold = 0.5f) const;
// 对任意尺寸的图:切片 -> 逐片推理 -> 平移回原图 -> 跨片 NMS。
std::vector<Box> infer(const ImageView& image, float conf = 0.25f,
float iou_threshold = 0.5f, int overlap = 32) const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace film
+49
View File
@@ -0,0 +1,49 @@
// 演示与对拍工具:读一张原始像素转储,推理并打印框。
//
// ./film_predict <model.xml> <image.bin> <宽> <高> <通道> [conf] [tile]
//
// 输入是连续的 HWC uint8 缓冲(用 scripts/dump_raw.py 从图片生成)。产线里通常直接
// 拿相机帧的指针,不需要这一步。tile 传 0 表示图本身就是网络输入尺寸。
#include "film_detector.hpp"
#include <cstdio>
#include <fstream>
#include <iostream>
#include <vector>
int main(int argc, char** argv) {
if (argc < 6) {
std::cerr << "用法: " << argv[0]
<< " <model.xml> <image.bin> <宽> <高> <通道> [conf] [tile-overlap]\n";
return 1;
}
const std::string model_path = argv[1], image_path = argv[2];
const int width = std::stoi(argv[3]), height = std::stoi(argv[4]);
const int channels = std::stoi(argv[5]);
const float conf = argc > 6 ? std::stof(argv[6]) : 0.25f;
const int overlap = argc > 7 ? std::stoi(argv[7]) : 32;
std::ifstream file(image_path, std::ios::binary);
if (!file) { std::cerr << "打不开 " << image_path << "\n"; return 1; }
std::vector<unsigned char> pixels((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
const size_t expected = static_cast<size_t>(width) * height * channels;
if (pixels.size() != expected) {
std::cerr << "缓冲大小 " << pixels.size() << " 与 " << width << "x" << height
<< "x" << channels << " 不符(应为 " << expected << ")\n";
return 1;
}
film::Detector detector(model_path);
film::ImageView image{pixels.data(), width, height, channels};
const auto boxes = (width == detector.input_size() && height == detector.input_size())
? detector.infer_tile(image, conf)
: detector.infer(image, conf, 0.5f, overlap);
std::printf("%zu\n", boxes.size());
for (const auto& b : boxes) {
std::printf("%d %.6f %.6f %.6f %.6f %.4f\n",
b.class_id, b.x1, b.y1, b.x2, b.y2, b.score);
}
return 0;
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python
"""把图片转成连续的 HWC uint8 缓冲,供 C++ 参考实现读取与对拍。
uv run python scripts/dump_raw.py 某张图.png /tmp/image.bin
打印 `宽 高 通道`,直接喂给 deploy/cpp 的 film_predict。
"""
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def main() -> int:
if len(sys.argv) != 3:
print(__doc__)
return 1
with Image.open(sys.argv[1]) as handle:
image = handle.convert("L") if handle.mode in ("L", "1", "I;16") else handle.convert("RGB")
array = np.asarray(image)
if array.ndim == 2:
array = array[..., None]
Path(sys.argv[2]).write_bytes(np.ascontiguousarray(array).tobytes())
print(array.shape[1], array.shape[0], array.shape[2])
return 0
if __name__ == "__main__":
raise SystemExit(main())