diff --git a/deploy/cpp/CMakeLists.txt b/deploy/cpp/CMakeLists.txt new file mode 100644 index 0000000..44bb661 --- /dev/null +++ b/deploy/cpp/CMakeLists.txt @@ -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) diff --git a/deploy/cpp/film_detector.cpp b/deploy/cpp/film_detector.cpp new file mode 100644 index 0000000..a752cd3 --- /dev/null +++ b/deploy/cpp/film_detector.cpp @@ -0,0 +1,241 @@ +#include "film_detector.hpp" + +#include + +#include +#include +#include +#include + +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 axis_starts(int length, int size, int overlap) { + if (length <= size) return {0}; + const int stride = std::max(1, size - overlap); + std::vector 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 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 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& out) { + const int channels = image.channels; + out.resize(static_cast(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(src_y) * image.width + src_x) * channels; + uint8_t* dst = out.data() + (static_cast(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 nms(std::vector boxes, float iou_threshold, bool per_class, + float contained_threshold) { + std::vector 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 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()) { + 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(input[1]); + impl_->size = static_cast(input[2]); + impl_->classes = static_cast(output[1]) - 4; + impl_->anchors = static_cast(output[2]); +} + +Detector::~Detector() = default; + +int Detector::input_size() const { return impl_->size; } +int Detector::num_classes() const { return impl_->classes; } + +std::vector 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(impl_->channels), + static_cast(size), + static_cast(size)}); + float* dst = input.data(); + const size_t plane = static_cast(size) * size; + for (int row = 0; row < size; ++row) { + for (int col = 0; col < size; ++col) { + const uint8_t* src = tile.data + + (static_cast(row) * size + col) * tile.channels; + const size_t offset = static_cast(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(value) / 255.f; + } + } + } + + impl_->request.set_input_tensor(input); + impl_->request.infer(); + const float* raw = impl_->request.get_output_tensor(0).data(); + + // 输出 [1, 4+nc, anchors]:前 4 行是像素 xywh(可能越界,要裁剪), + // 其余每行一个类别的分数,已过 sigmoid。end2end=false,必须自己做 NMS。 + const int anchors = impl_->anchors; + std::vector 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 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 patch; + std::vector 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(image.width)); + const float cy2 = std::min(py2, static_cast(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 diff --git a/deploy/cpp/film_detector.hpp b/deploy/cpp/film_detector.hpp new file mode 100644 index 0000000..e037a00 --- /dev/null +++ b/deploy/cpp/film_detector.hpp @@ -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 +#include +#include +#include + +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 axis_starts(int length, int size, int overlap); + +// 把 width×height 切成若干 size×size 的片。 +std::vector 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& 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 nms(std::vector 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 infer_tile(const ImageView& tile, float conf = 0.25f, + float iou_threshold = 0.5f) const; + + // 对任意尺寸的图:切片 -> 逐片推理 -> 平移回原图 -> 跨片 NMS。 + std::vector infer(const ImageView& image, float conf = 0.25f, + float iou_threshold = 0.5f, int overlap = 32) const; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace film diff --git a/deploy/cpp/main.cpp b/deploy/cpp/main.cpp new file mode 100644 index 0000000..56ebc63 --- /dev/null +++ b/deploy/cpp/main.cpp @@ -0,0 +1,49 @@ +// 演示与对拍工具:读一张原始像素转储,推理并打印框。 +// +// ./film_predict <宽> <高> <通道> [conf] [tile] +// +// 输入是连续的 HWC uint8 缓冲(用 scripts/dump_raw.py 从图片生成)。产线里通常直接 +// 拿相机帧的指针,不需要这一步。tile 传 0 表示图本身就是网络输入尺寸。 +#include "film_detector.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc < 6) { + std::cerr << "用法: " << argv[0] + << " <宽> <高> <通道> [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 pixels((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + const size_t expected = static_cast(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; +} diff --git a/scripts/dump_raw.py b/scripts/dump_raw.py new file mode 100644 index 0000000..f6193df --- /dev/null +++ b/scripts/dump_raw.py @@ -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())