diff --git a/pom.xml b/pom.xml
index 869a8be..9a291a9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -115,11 +115,11 @@
lombok
1.18.32
-
+
org.springdoc
- springdoc-openapi-ui
- 1.6.15
+ springdoc-openapi-starter-webmvc-ui
+ 2.3.0
diff --git a/src/main/java/com/example/lxcameraapi/controller/CategoryController.java b/src/main/java/com/example/lxcameraapi/controller/CategoryController.java
index ab1a89c..119954f 100644
--- a/src/main/java/com/example/lxcameraapi/controller/CategoryController.java
+++ b/src/main/java/com/example/lxcameraapi/controller/CategoryController.java
@@ -7,12 +7,17 @@ import com.example.lxcameraapi.service.IndustrialCamera.algorithm.ONNXServiceNew
import com.example.lxcameraapi.service.IndustrialCamera.camera.hik.ImageCaptureService;
import com.example.lxcameraapi.service.IndustrialCamera.camera.lx.config.BoxCountRequest;
import com.example.lxcameraapi.service.IndustrialCamera.camera.lx.config.BoxCountResponse;
+import com.example.lxcameraapi.service.IndustrialCamera.camera.lx.config.CaptureResult;
import com.example.lxcameraapi.service.IndustrialCamera.opencv.OpencvService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.Socket;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -36,6 +41,91 @@ public class CategoryController {
@Resource
private AsyncProcessingService asyncProcessingService;
+ @Resource
+ private LxCameraController lxCameraService;
+
+
+
+ /**
+ * TCP触发连拍 - 连接TCP服务端,发送start,返回!=NoRead时连拍10次(hik + lx)
+ */
+ @PostMapping("/main")
+ public BoxCountResponse main() {
+
+ BoxCountResponse boxCountResponse = new BoxCountResponse();
+ Integer cameraId = 1;
+ String tcpIp = "127.0.0.1";
+ Integer tcpPort = 8080;
+ int photoCount = 10;
+
+ // 从配置获取hik相机IP
+ String cameraIp = null;
+ for (AppConfig.Camera camera : appConfig.getHikCamera()) {
+ if (camera.getId().equals(cameraId)) {
+ cameraIp = camera.getIp();
+ break;
+ }
+ }
+
+ try (Socket socket = new Socket(tcpIp, tcpPort)) {
+ log.info("已连接TCP服务端 {}:{}", tcpIp, tcpPort);
+
+ PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
+ BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
+
+ // 发送 "start"
+ out.println("start");
+ log.info("已发送: start");
+
+
+ // 读取返回
+ String response = in.readLine();
+ log.info("收到返回: {}", response);
+
+ if (response != null && !"NoRead".equals(response.trim())) {
+ log.info("返回不是NoRead,开始连拍{}次", photoCount);
+
+ LocalDateTime now = LocalDateTime.now();
+ DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ String formattedDate = now.format(dateFormatter);
+ String basePath = appConfig.getPicPath() + formattedDate + "/" + cameraId + "/";
+
+ for (int i = 0; i < photoCount; i++) {
+ try {
+ DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss-SSS");
+ String formattedTime = LocalDateTime.now().format(timeFormatter);
+
+ // Hik相机拍照
+ String hikPath = basePath + formattedTime + "_hik.png";
+ boolean success = imageCaptureService.captureImage(cameraIp, hikPath);
+ log.info("Hik拍照 {}/{}: {} -> {}", i + 1, photoCount, success ? "成功" : "失败", hikPath);
+
+ // Lx相机拍照
+ lxCameraService.capture("1", basePath);
+ log.info("Lx拍照 {}/{} 完成", i + 1, photoCount);
+
+ } catch (Exception e) {
+ log.error("连拍 {}/{} 异常", i + 1, photoCount, e);
+ }
+ }
+
+ boxCountResponse.setResult("BatchDone");
+ boxCountResponse.setSuccess(true);
+ log.info("连拍完成");
+ } else {
+ boxCountResponse.setResult("NoRead");
+ boxCountResponse.setSuccess(true);
+ log.info("返回NoRead,不触发拍照");
+ }
+
+ } catch (Exception e) {
+ log.error("TCP连接或拍照异常", e);
+ boxCountResponse.setResult("Error");
+ boxCountResponse.setSuccess(false);
+ }
+
+ return boxCountResponse;
+ }
/**
* 品规识别 - 拍照同步,识别异步,完成后发送HTTP通知
*/
diff --git a/src/main/java/com/example/lxcameraapi/service/IndustrialCamera/opencv/OpencvService.java b/src/main/java/com/example/lxcameraapi/service/IndustrialCamera/opencv/OpencvService.java
index ddc2f03..1960256 100644
--- a/src/main/java/com/example/lxcameraapi/service/IndustrialCamera/opencv/OpencvService.java
+++ b/src/main/java/com/example/lxcameraapi/service/IndustrialCamera/opencv/OpencvService.java
@@ -389,5 +389,440 @@ public class OpencvService {
clahe.release();
return binary;
}
+
+ // ==================== 图像融合 ====================
+
+ /**
+ * 1. 加权融合 - 两张图按透明度线性混合
+ * result = img1 * (1-alpha) + img2 * alpha
+ *
+ * @param img1Path 底图路径
+ * @param img2Path 叠加图路径
+ * @param destPath 输出路径
+ * @param alpha 融合权重(0~1),alpha越大img2越明显
+ */
+ public void blendWeighted(String img1Path, String img2Path, String destPath, double alpha) {
+ Mat img1 = Imgcodecs.imread(img1Path);
+ Mat img2 = Imgcodecs.imread(img2Path);
+
+ if (img1.empty() || img2.empty()) {
+ log.error("读取图片失败");
+ closeMats(img1, img2);
+ return;
+ }
+
+ // 统一尺寸为img1
+ if (img2.size() != img1.size()) {
+ Imgproc.resize(img2, img2, img1.size());
+ }
+
+ Mat result = blendWeighted(img1, img2, alpha);
+ Imgcodecs.imwrite(destPath, result);
+ log.info("加权融合完成,alpha={}, 输出: {}", alpha, destPath);
+
+ closeMats(result, img1, img2);
+ }
+
+ /**
+ * 加权融合 Mat 版本
+ */
+ public Mat blendWeighted(Mat img1, Mat img2, double alpha) {
+ Mat result = new Mat();
+ Core.addWeighted(img1, 1.0 - alpha, img2, alpha, 0, result);
+ return result;
+ }
+
+ /**
+ * 2. ROI 区域融合 - 将小图贴到大图的指定位置(支持透明蒙版)
+ *
+ * @param bigPath 大图路径
+ * @param smallPath 小图路径
+ * @param destPath 输出路径
+ * @param x 贴入位置X
+ * @param y 贴入位置Y
+ * @param alpha 融合透明度(0=完全不透明覆盖, 1=完全透明)
+ */
+ public void blendRoi(String bigPath, String smallPath, String destPath, int x, int y, double alpha) {
+ Mat big = Imgcodecs.imread(bigPath);
+ Mat small = Imgcodecs.imread(smallPath);
+
+ if (big.empty() || small.empty()) {
+ log.error("读取图片失败");
+ closeMats(big, small);
+ return;
+ }
+
+ Mat result = blendRoi(big, small, x, y, alpha);
+ Imgcodecs.imwrite(destPath, result);
+ log.info("ROI融合完成,位置({},{}), 输出: {}", x, y, destPath);
+
+ closeMats(result, big, small);
+ }
+
+ /**
+ * ROI 区域融合 Mat 版本
+ */
+ public Mat blendRoi(Mat big, Mat small, int x, int y, double alpha) {
+ // 裁剪超界部分
+ int w = Math.min(small.cols(), big.cols() - x);
+ int h = Math.min(small.rows(), big.rows() - y);
+ if (w <= 0 || h <= 0) {
+ log.error("小图超出大图范围");
+ return big.clone();
+ }
+
+ Mat roi = big.submat(new Rect(x, y, w, h));
+ Mat smallCrop = small.submat(new Rect(0, 0, w, h));
+
+ if (alpha > 0) {
+ // 半透明融合
+ Core.addWeighted(roi, alpha, smallCrop, 1.0 - alpha, 0, roi);
+ } else {
+ // 直接覆盖
+ smallCrop.copyTo(roi);
+ }
+
+ return big.clone();
+ }
+
+ /**
+ * 3. 蒙版融合 - 通过mask控制融合区域
+ * mask中白色区域显示img2,黑色区域显示img1
+ *
+ * @param img1Path 底图路径
+ * @param img2Path 叠加图路径
+ * @param maskPath 蒙版路径(黑白图,白色=显示img2)
+ * @param destPath 输出路径
+ */
+ public void blendByMask(String img1Path, String img2Path, String maskPath, String destPath) {
+ Mat img1 = Imgcodecs.imread(img1Path);
+ Mat img2 = Imgcodecs.imread(img2Path);
+ Mat mask = Imgcodecs.imread(maskPath, Imgcodecs.IMREAD_GRAYSCALE);
+
+ if (img1.empty() || img2.empty() || mask.empty()) {
+ log.error("读取图片失败");
+ closeMats(img1, img2, mask);
+ return;
+ }
+
+ // 统一尺寸
+ Size size = img1.size();
+ if (!img2.size().equals(size)) Imgproc.resize(img2, img2, size);
+ if (!mask.size().equals(size)) Imgproc.resize(mask, mask, size);
+
+ Mat result = blendByMask(img1, img2, mask);
+ Imgcodecs.imwrite(destPath, result);
+ log.info("蒙版融合完成,输出: {}", destPath);
+
+ closeMats(result, img1, img2, mask);
+ }
+
+ /**
+ * 蒙版融合 Mat 版本
+ */
+ public Mat blendByMask(Mat img1, Mat img2, Mat mask) {
+ // 归一化mask到0~1
+ Mat maskNorm = new Mat();
+ mask.convertTo(maskNorm, CvType.CV_32FC1, 1.0 / 255.0);
+
+ // 分别取img1和img2各通道
+ Mat result = new Mat(img1.size(), img1.type());
+ for (int c = 0; c < img1.channels(); c++) {
+ Core.multiply(img1, Scalar.all(1.0), result); // 先全取img1...简化处理,直接像素级混合
+ }
+
+ // 浮点运算: result = img1 * (1-mask) + img2 * mask
+ Mat img1f = new Mat();
+ Mat img2f = new Mat();
+ img1.convertTo(img1f, CvType.CV_32FC3);
+ img2.convertTo(img2f, CvType.CV_32FC3);
+
+ Mat resultF = new Mat();
+ // resultF = img2 * mask
+ List maskChannels = new ArrayList<>();
+ for (int i = 0; i < 3; i++) maskChannels.add(maskNorm);
+ Mat mask3 = new Mat();
+ Core.merge(maskChannels, mask3);
+ Core.multiply(img2f, mask3, resultF);
+
+ // resultF += img1 * (1-mask)
+ Mat invMask = new Mat();
+// Core.subtract(Scalar.all(1.0), mask3, invMask);
+ Mat img1Masked = new Mat();
+ Core.multiply(img1f, invMask, img1Masked);
+ Core.add(resultF, img1Masked, resultF);
+
+ resultF.convertTo(result, CvType.CV_8UC3);
+
+// closeMats(maskNorm, mask3, invMask, img1Masked, resultF, img1f, img2f, maskChannels.toArray(new Mat[0]));
+ return result;
+ }
+
+ /**
+ * 4. 拉普拉斯金字塔融合 - 多尺度融合,边缘过渡自然
+ * 适合将两张图无缝拼接,接缝处平滑过渡
+ *
+ * @param img1Path 左侧/上方图路径
+ * @param img2Path 右侧/下方图路径
+ * @param destPath 输出路径
+ * @param levels 金字塔层数(推荐4~6)
+ * @param horizontal true=水平拼接融合, false=垂直拼接融合
+ */
+ public void blendPyramid(String img1Path, String img2Path, String destPath, int levels, boolean horizontal) {
+ Mat img1 = Imgcodecs.imread(img1Path);
+ Mat img2 = Imgcodecs.imread(img2Path);
+
+ if (img1.empty() || img2.empty()) {
+ log.error("读取图片失败");
+ closeMats(img1, img2);
+ return;
+ }
+
+ // 统一尺寸(按较大者)
+ int h = Math.max(img1.rows(), img2.rows());
+ int w = Math.max(img1.cols(), img2.cols());
+ if (img1.rows() != h || img1.cols() != w) Imgproc.resize(img1, img1, new Size(w, h));
+ if (img2.rows() != h || img2.cols() != w) Imgproc.resize(img2, img2, new Size(w, h));
+
+ Mat result = blendPyramid(img1, img2, levels, horizontal);
+ Imgcodecs.imwrite(destPath, result);
+ log.info("金字塔融合完成,levels={}, 输出: {}", levels, destPath);
+
+ closeMats(result, img1, img2);
+ }
+
+ /**
+ * 拉普拉斯金字塔融合 Mat 版本
+ */
+ public Mat blendPyramid(Mat img1, Mat img2, int levels, boolean horizontal) {
+ // 确保是3通道
+ Mat i1 = img1.channels() == 3 ? img1.clone() : new Mat();
+ Mat i2 = img2.channels() == 3 ? img2.clone() : new Mat();
+ if (img1.channels() == 1) Imgproc.cvtColor(img1, i1, Imgproc.COLOR_GRAY2BGR);
+ if (img2.channels() == 1) Imgproc.cvtColor(img2, i2, Imgproc.COLOR_GRAY2BGR);
+
+ // 构建蒙版:一半白一半黑
+ Mat mask = Mat.zeros(i1.size(), CvType.CV_8UC1);
+ if (horizontal) {
+ Imgproc.rectangle(mask, new Rect(0, 0, i1.cols() / 2, i1.rows()), new Scalar(255), -1);
+ } else {
+ Imgproc.rectangle(mask, new Rect(0, 0, i1.cols(), i1.rows() / 2), new Scalar(255), -1);
+ }
+
+ // 构建高斯金字塔(mask平滑版本)
+ Mat maskFloat = new Mat();
+ mask.convertTo(maskFloat, CvType.CV_32FC1, 1.0 / 255.0);
+ Mat maskBlur = new Mat();
+ Imgproc.GaussianBlur(maskFloat, maskBlur, new Size(31, 31), 10);
+
+ // 三通道mask
+ List mc = new ArrayList<>();
+ for (int i = 0; i < 3; i++) mc.add(maskBlur);
+ Mat mask3 = new Mat();
+ Core.merge(mc, mask3);
+
+ // 拉普拉斯金字塔:对每一层做下采样-上采样-相减得到高频细节
+ Mat i1f = new Mat(), i2f = new Mat();
+ i1.convertTo(i1f, CvType.CV_32FC3, 1.0 / 255.0);
+ i2.convertTo(i2f, CvType.CV_32FC3, 1.0 / 255.0);
+
+ List pyr1 = buildLaplacianPyramid(i1f, levels);
+ List pyr2 = buildLaplacianPyramid(i2f, levels);
+ List pyrMask = buildGaussianPyramid(mask3, levels);
+
+ // 每层融合
+ List pyrResult = new ArrayList<>();
+ for (int i = 0; i < levels; i++) {
+ Mat layer = new Mat();
+// Core.add(
+// pyr2.get(i).mul(pyrMask.get(i)),
+// pyr1.get(i).mul(Scalar.all(1.0)).sub(pyr1.get(i).mul(pyrMask.get(i))),
+// // 简化:直接mask混合
+// layer
+// );
+ // 更准确的写法
+ Mat term1 = new Mat(), term2 = new Mat();
+ Core.multiply(pyr2.get(i), pyrMask.get(i), term1);
+ Mat invMask = new Mat();
+// Core.subtract(Scalar.all(1.0), pyrMask.get(i), invMask);
+ Core.multiply(pyr1.get(i), invMask, term2);
+ Core.add(term1, term2, layer);
+ pyrResult.add(layer);
+
+ closeMats(term1, term2, invMask);
+ }
+
+ // 重建
+ Mat result = reconstructFromPyramid(pyrResult);
+
+ result.convertTo(result, CvType.CV_8UC3, 255.0);
+
+ // 释放金字塔
+ pyr1.forEach(Mat::release);
+ pyr2.forEach(Mat::release);
+ pyrMask.forEach(Mat::release);
+ pyrResult.forEach(Mat::release);
+ closeMats(mask, maskFloat, maskBlur, mask3, i1f, i2f, i1, i2);
+
+ return result;
+ }
+
+ /**
+ * 5. 多频段融合 (Multi-Band Blending) - 更高质量的拼接融合
+ * 适合全景图拼接、两张不同曝光图融合等场景
+ *
+ * @param img1Path 底图路径
+ * @param img2Path 叠加图路径
+ * @param destPath 输出路径
+ * @param bands 频段数(推荐4~8)
+ */
+ public void blendMultiBand(String img1Path, String img2Path, String destPath, int bands) {
+ Mat img1 = Imgcodecs.imread(img1Path);
+ Mat img2 = Imgcodecs.imread(img2Path);
+
+ if (img1.empty() || img2.empty()) {
+ log.error("读取图片失败");
+ closeMats(img1, img2);
+ return;
+ }
+
+ // 统一尺寸
+ if (!img1.size().equals(img2.size())) {
+ Imgproc.resize(img2, img2, img1.size());
+ }
+
+ Mat result = blendMultiBand(img1, img2, bands);
+ Imgcodecs.imwrite(destPath, result);
+ log.info("多频段融合完成,bands={}, 输出: {}", bands, destPath);
+
+ closeMats(result, img1, img2);
+ }
+
+ /**
+ * 多频段融合 Mat 版本
+ */
+ public Mat blendMultiBand(Mat img1, Mat img2, int bands) {
+ // 转3通道
+ Mat i1 = toBgr(img1);
+ Mat i2 = toBgr(img2);
+
+ // 高斯金字塔 smooth 后连加
+ List gauss1 = new ArrayList<>();
+ List gauss2 = new ArrayList<>();
+ Mat current = i1.clone();
+ gauss1.add(current.clone());
+
+ for (int i = 0; i < bands; i++) {
+ Mat down = new Mat();
+ Imgproc.pyrDown(current, down);
+ current = down;
+ gauss1.add(current.clone());
+ }
+ closeMats(current);
+
+ current = i2.clone();
+ gauss2.add(current.clone());
+ for (int i = 0; i < bands; i++) {
+ Mat down = new Mat();
+ Imgproc.pyrDown(current, down);
+ current = down;
+ gauss2.add(current.clone());
+ }
+ closeMats(current);
+
+ // 重建最高层
+ Mat topLayer = new Mat();
+ Core.addWeighted(gauss1.get(bands), 0.5, gauss2.get(bands), 0.5, 0, topLayer);
+
+ Mat result = topLayer;
+ for (int i = bands - 1; i >= 0; i--) {
+ Mat up = new Mat();
+ Imgproc.pyrUp(result, up, gauss1.get(i).size());
+ Mat blended = new Mat();
+ Core.addWeighted(gauss1.get(i), 0.5, gauss2.get(i), 0.5, 0, blended);
+ Core.add(up, blended, result = new Mat());
+ closeMats(up, blended);
+ }
+
+ gauss1.forEach(Mat::release);
+ gauss2.forEach(Mat::release);
+ closeMats(i1, i2);
+
+ return result;
+ }
+
+ // ==================== 融合辅助方法 ====================
+
+ /**
+ * 构建高斯金字塔
+ */
+ private List buildGaussianPyramid(Mat src, int levels) {
+ List pyramid = new ArrayList<>();
+ Mat current = src.clone();
+ pyramid.add(current);
+ for (int i = 1; i < levels; i++) {
+ Mat down = new Mat();
+ Imgproc.pyrDown(current, down);
+ pyramid.add(down);
+ current = down;
+ }
+ return pyramid;
+ }
+
+ /**
+ * 构建拉普拉斯金字塔
+ */
+ private List buildLaplacianPyramid(Mat src, int levels) {
+ List gauss = buildGaussianPyramid(src, levels);
+ List laplace = new ArrayList<>();
+
+ for (int i = 0; i < levels - 1; i++) {
+ Mat up = new Mat();
+ Imgproc.pyrUp(gauss.get(i + 1), up, gauss.get(i).size());
+ Mat lap = new Mat();
+ Core.subtract(gauss.get(i), up, lap);
+ laplace.add(lap);
+ closeMats(up);
+ }
+ laplace.add(gauss.get(levels - 1).clone());
+
+ gauss.forEach(Mat::release);
+ return laplace;
+ }
+
+ /**
+ * 从金字塔重建原图
+ */
+ private Mat reconstructFromPyramid(List pyramid) {
+ Mat result = pyramid.get(pyramid.size() - 1).clone();
+ for (int i = pyramid.size() - 2; i >= 0; i--) {
+ Mat up = new Mat();
+ Imgproc.pyrUp(result, up, pyramid.get(i).size());
+ Mat sum = new Mat();
+ Core.add(up, pyramid.get(i), sum);
+ result = sum;
+ closeMats(up);
+ }
+ return result;
+ }
+
+ /**
+ * 转为BGR三通道
+ */
+ private Mat toBgr(Mat src) {
+ if (src.channels() == 3) return src.clone();
+ Mat bgr = new Mat();
+ Imgproc.cvtColor(src, bgr, Imgproc.COLOR_GRAY2BGR);
+ return bgr;
+ }
+
+ /**
+ * 批量释放 Mat
+ */
+ private void closeMats(Mat... mats) {
+ for (Mat m : mats) {
+ if (m != null) m.release();
+ }
+ }
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 976fac1..21adcd8 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -55,7 +55,7 @@ picPath: "D:/data/media/"
picUrl: "http://192.168.100.110:9012/pic/"
serve:
- ip: 127.0.0.1
+ ip: 192.168.100.99
port: 48080
singleInventoryReturnPath: "/admin-api/logistics/StockController/singleInventoryReturn"
singleInventoryQrCodeReturnPath: "/admin-api/logistics/StockController/singleInventoryQrCodeReturn"