package com.zskk.qconline.modules.qc.engine; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.zskk.qconline.modules.dicom.service.DicomImageService; import com.zskk.qconline.modules.entity.StudyInfo; import com.zskk.qconline.modules.mapper.StudyInfoMapper; import lombok.extern.slf4j.Slf4j; import org.opencv.core.Core; import org.opencv.core.CvType; import org.opencv.core.Mat; import org.opencv.core.MatOfDouble; import org.opencv.imgcodecs.Imgcodecs; import org.opencv.imgproc.Imgproc; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.io.File; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * OpenCV图像质控分析器 * * @author system * @date 2025-12-27 */ @Slf4j @Service public class OpenCVImageAnalyzer { @Resource private DicomImageService dicomImageService; @Resource private StudyInfoMapper studyInfoMapper; // DICOM文件存储根路径(与DicomImageServiceImpl中的一致) private static final String DICOM_ROOT_PATH = System.getProperty("user.home") + "/qconline/dicom/"; static { // OpenCV本地库在DicomConfig中已经加载,这里只是验证和记录版本信息 try { String version = Core.VERSION; log.info("OpenCVImageAnalyzer 使用的 OpenCV 版本: {}", version); } catch (UnsatisfiedLinkError e) { log.warn("OpenCV原生库未加载,某些功能可能受限: {}", e.getMessage()); } catch (Exception e) { log.error("获取OpenCV版本信息失败: {}", e.getMessage(), e); } } /** * 将DICOM目录路径转换为临时PNG文件路径列表 * @param studyId 检查ID * @param dicomRelPath DICOM相对路径(从数据库获取) * @return 临时PNG文件的绝对路径列表,如果转换失败返回空列表 */ private java.util.List convertDicomToTempImages(String studyId, String dicomRelPath) { java.util.List tempImages = new java.util.ArrayList<>(); try { log.info("开始转换DICOM到临时图片: {}", dicomRelPath); // 1. 构建完整的DICOM文件目录路径 String fullPath = DICOM_ROOT_PATH + dicomRelPath; log.info("DICOM完整路径: {}", fullPath); File studyDir = new File(fullPath); if (!studyDir.exists() || !studyDir.isDirectory()) { log.warn("DICOM目录不存在或不是目录: {}", fullPath); return tempImages; } // 2. 获取所有DICOM文件 File[] dicomFiles = studyDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".dcm") || name.toLowerCase().endsWith(".dicom")); if (dicomFiles == null || dicomFiles.length == 0) { log.warn("DICOM目录中没有文件: {}", fullPath); return tempImages; } log.info("找到{}个DICOM文件", dicomFiles.length); // 3. 按实例号排序(使用DicomImageServiceImpl中的排序逻辑) java.util.Arrays.sort(dicomFiles, (f1, f2) -> { Integer n1 = getInstanceNumber(f1); Integer n2 = getInstanceNumber(f2); if (n1 == null && n2 == null) return 0; if (n1 == null) return 1; if (n2 == null) return -1; return n1.compareTo(n2); }); // 4. 将每个DICOM文件转换为临时PNG(限制最多转换20个文件,避免性能问题) int maxFiles = Math.min(dicomFiles.length, 20); String tempDir = System.getProperty("java.io.tmpdir"); for (int i = 0; i < maxFiles; i++) { try { File dicomFile = dicomFiles[i]; log.info("转换第{}/{}个DICOM文件: {}", i + 1, maxFiles, dicomFile.getName()); // 使用DicomImageService转换为BufferedImage byte[] pngBytes = dicomImageService.getDicomImage(studyId, i); if (pngBytes == null || pngBytes.length == 0) { log.warn("DICOM文件{}转换失败,跳过", dicomFile.getName()); continue; } // 写入临时文件 String tempFileName = "qc_temp_" + System.currentTimeMillis() + "_" + i + "_" + dicomFile.getName() + ".png"; File tempFile = new File(tempDir, tempFileName); try (FileOutputStream fos = new FileOutputStream(tempFile)) { fos.write(pngBytes); fos.flush(); } tempImages.add(tempFile.getAbsolutePath()); log.info("成功创建临时图片: {}", tempFile.getAbsolutePath()); } catch (Exception e) { log.error("转换DICOM文件失败: {}", dicomFiles[i].getName(), e); } } log.info("成功转换{}个DICOM文件为临时图片", tempImages.size()); return tempImages; } catch (Exception e) { log.error("批量转换DICOM到临时图片失败: {}", dicomRelPath, e); return tempImages; } } /** * 从DICOM文件中获取实例号 */ private Integer getInstanceNumber(File dicomFile) { try { org.dcm4che3.io.DicomInputStream dis = new org.dcm4che3.io.DicomInputStream(dicomFile); org.dcm4che3.data.Attributes attrs = dis.readDataset(-1, -1); if (attrs.contains(org.dcm4che3.data.Tag.InstanceNumber)) { return attrs.getInt(org.dcm4che3.data.Tag.InstanceNumber, 0); } return null; } catch (Exception e) { log.warn("获取实例号失败: {}", dicomFile.getName()); return null; } } /** * 清理临时图片文件列表 * @param imagePaths 图片文件路径列表 */ private void cleanupTempFiles(java.util.List imagePaths) { if (imagePaths == null || imagePaths.isEmpty()) { return; } int cleanedCount = 0; for (String imagePath : imagePaths) { if (imagePath != null && imagePath.contains("qc_temp_")) { try { File file = new File(imagePath); if (file.exists() && file.getName().startsWith("qc_temp_")) { boolean deleted = file.delete(); if (deleted) { cleanedCount++; } } } catch (Exception e) { log.warn("清理临时文件失败: {}", imagePath, e); } } } if (cleanedCount > 0) { log.info("清理临时文件: 成功删除{}/{}", cleanedCount, imagePaths.size()); } } /** * 分析检查范围 * @param imagePath DICOM文件路径 * @return "large"(过大)/"normal"(正常)/"small"(过小) */ public String analyzeCheckRange(String imagePath) { log.info("========== 开始分析检查范围 =========="); log.info("图像路径: {}", imagePath); try { Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); if (src.empty()) { log.warn("无法读取图像,返回默认值normal"); return "normal"; } log.info("图像尺寸: {}x{}", src.cols(), src.rows()); // 1. 二值化 Mat binary = new Mat(); Imgproc.threshold(src, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU); log.info("二值化完成,使用OTSU自动阈值"); // 2. 计算有效成像区域占比 int totalPixels = src.rows() * src.cols(); int validPixels = Core.countNonZero(binary); double validRatio = (double) validPixels / totalPixels; log.info("有效像素统计: 总像素={}, 有效像素={}, 有效率={:.2f}%", totalPixels, validPixels, validRatio * 100); // 3. 判断检查范围 String result; if (validRatio > 0.85) { result = "large"; log.warn("检查范围判定: 过大 (有效率为{:.2f}% > 85%)", validRatio * 100); } else if (validRatio < 0.15) { result = "small"; log.warn("检查范围判定: 过小 (有效率为{:.2f}% < 15%)", validRatio * 100); } else { result = "normal"; log.info("检查范围判定: 正常 (有效率在15%-85%之间)"); } log.info("检查范围分析完成: 结果={}", result); return result; } catch (Exception e) { log.error("检查范围分析失败: {}", e.getMessage(), e); return "normal"; } } /** * 分析体位 * @param imagePath DICOM文件路径 * @return "standard"(标准)/"non_standard"(不标准) */ public String analyzeBodyPosition(String imagePath) { log.info("========== 开始分析体位 =========="); try { Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); if (src.empty()) { log.warn("无法读取图像,返回默认值standard"); return "standard"; } // 1. 边缘检测 Mat edges = new Mat(); Imgproc.Canny(src, edges, 50, 150); log.info("Canny边缘检测完成,阈值=50-150"); // 2. 霍夫变换检测直线 Mat lines = new Mat(); Imgproc.HoughLinesP(edges, lines, 1, Math.PI / 180, 80, 100, 10); log.info("霍夫直线变换完成,检测到{}条直线", lines.rows()); // 3. 分析对称性和骨架位置 List leftAngles = new ArrayList<>(); List rightAngles = new ArrayList<>(); for (int i = 0; i < lines.rows(); i++) { double[] data = lines.get(i, 0); double x1 = data[0], y1 = data[1]; double x2 = data[2], y2 = data[3]; double angle = Math.toDegrees(Math.atan2(y2 - y1, x2 - x1)); if (x1 < src.cols() / 2) { leftAngles.add((int) angle); } else { rightAngles.add((int) angle); } } log.info("左侧直线数={}, 右侧直线数={}", leftAngles.size(), rightAngles.size()); // 4. 判断对称性(简单判断) int diff = Math.abs(leftAngles.size() - rightAngles.size()); boolean isSymmetric = diff < 5; String result; if (isSymmetric && lines.rows() > 10) { result = "standard"; log.info("体位判定: 标准 (直线数={}, 左右差={})", lines.rows(), diff); } else { result = "non_standard"; log.warn("体位判定: 不标准 (直线数={}, 左右差={})", lines.rows(), diff); } log.info("体位分析完成: 结果={}", result); return result; } catch (Exception e) { log.error("体位分析失败: {}", e.getMessage(), e); return "standard"; } } /** * 分析图像伪影 * @param imagePath DICOM文件路径 * @return "none"(无伪影)/"foreign_body"(体外异物)/"device"(设备伪影) */ public String analyzeImageArtifact(String imagePath) { log.info("========== 开始分析图像伪影 =========="); try { Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); if (src.empty()) { log.warn("无法读取图像,返回默认值none"); return "none"; } log.info("图像尺寸: {}x{}", src.cols(), src.rows()); // 1. 检测运动伪影(模糊检测) Mat laplacian = new Mat(); Laplacian(src, laplacian, CvType.CV_64F); Mat absLaplacian = new Mat(); Core.convertScaleAbs(laplacian, absLaplacian); double variance = getVariance(absLaplacian); log.info("Laplacian方差检测完成: 方差={:.2f} (阈值=100)", variance); // 2. 检测高亮区域(金属伪影) Mat bright = new Mat(); Imgproc.threshold(src, bright, 240, 255, Imgproc.THRESH_BINARY); int brightPixels = Core.countNonZero(bright); double brightRatio = (double) brightPixels / (src.rows() * src.cols()); log.info("高亮区域检测完成: 高亮像素={}, 占比={:.2f}% (阈值=5%)", brightPixels, brightRatio * 100); // 3. 噪声检测 MatOfDouble meanMat = new MatOfDouble(); MatOfDouble stddevMat = new MatOfDouble(); Core.meanStdDev(src, meanMat, stddevMat); double noiseLevel = stddevMat.get(0, 0)[0]; log.info("噪声水平检测完成: 标准差={:.2f} (阈值=50)", noiseLevel); // 4. 判断伪影类型 String result; if (variance < 100) { result = "motion_artifact"; // 运动伪影 log.warn("伪影判定: 运动伪影 (Laplacian方差={:.2f} < 100)", variance); } else if (brightRatio > 0.05) { result = "foreign_body"; // 体外异物伪影 log.warn("伪影判定: 体外异物伪影 (高亮占比={:.2f}% > 5%)", brightRatio * 100); } else if (noiseLevel > 50) { result = "device"; // 设备伪影 log.warn("伪影判定: 设备伪影 (噪声水平={:.2f} > 50)", noiseLevel); } else { result = "none"; // 无伪影 log.info("伪影判定: 无伪影"); } log.info("图像伪影分析完成: 结果={}", result); return result; } catch (Exception e) { log.error("伪影分析失败: {}", e.getMessage(), e); return "none"; } } /** * 分析中心线 * @param imagePath DICOM文件路径 * @return "up"(偏上)/"down"(偏下)/"left"(偏左)/"right"(偏右)/"normal"(正常) */ public String analyzeCenterLine(String imagePath) { log.info("========== 开始分析中心线 =========="); try { Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); if (src.empty()) { log.warn("无法读取图像,返回默认值normal"); return "normal"; } log.info("图像尺寸: {}x{}", src.cols(), src.rows()); // 1. 边缘检测 Mat edges = new Mat(); Imgproc.Canny(src, edges, 50, 150); log.info("Canny边缘检测完成,阈值=50-150"); // 2. 霍夫直线变换检测脊柱/中心线 Mat lines = new Mat(); Imgproc.HoughLines(edges, lines, 1, Math.PI / 180, 100); log.info("霍夫直线变换完成,检测到{}条直线", lines.rows()); if (lines.rows() == 0) { log.warn("未检测到直线,返回默认值normal"); return "normal"; } // 3. 分析中心线位置 List angles = new ArrayList<>(); List offsets = new ArrayList<>(); int analysisCount = Math.min(lines.rows(), 10); for (int i = 0; i < analysisCount; i++) { double[] data = lines.get(i, 0); double rho = data[0]; double theta = data[1]; double angle = Math.toDegrees(theta); angles.add(angle); // 计算中心偏移 double a = Math.cos(theta); double b = Math.sin(theta); double x0 = a * rho; double offset = Math.abs(x0 - src.cols() / 2); offsets.add(offset); } log.info("分析前{}条直线: 角度数={}, 偏移量数={}", analysisCount, angles.size(), offsets.size()); // 4. 判断偏离方向 double avgOffset = offsets.stream().mapToDouble(Double::doubleValue).average().orElse(0); double offsetRatio = avgOffset / (src.cols() / 2); log.info("水平偏移分析: 平均偏移={:.2f}px, 偏移率={:.2f}% (阈值=15%)", avgOffset, offsetRatio * 100); String result; if (offsetRatio > 0.15) { result = "left"; // 偏左 log.warn("中心线判定: 偏左 (偏移率={:.2f}% > 15%)", offsetRatio * 100); } else if (offsetRatio < -0.15) { result = "right"; // 偏右 log.warn("中心线判定: 偏右 (偏移率={:.2f}% < -15%)", offsetRatio * 100); } else { // 分析垂直偏移 double avgAngle = angles.stream().mapToDouble(Double::doubleValue).average().orElse(90); log.info("角度分析: 平均角度={:.2f}°, 偏离90°={:.2f}° (阈值=10°)", avgAngle, Math.abs(avgAngle - 90)); if (Math.abs(avgAngle - 90) > 10) { result = avgAngle < 90 ? "up" : "down"; log.warn("中心线判定: {} (角度={:.2f}°, 偏离={:.2f}° > 10°)", result.equals("up") ? "偏上" : "偏下", avgAngle, Math.abs(avgAngle - 90)); } else { result = "normal"; log.info("中心线判定: 正常 (偏移率和角度都在正常范围内)"); } } log.info("中心线分析完成: 结果={}", result); return result; } catch (Exception e) { log.error("中心线分析失败: {}", e.getMessage(), e); return "normal"; } } /** * 评定图像质控等级 * @param imagePath DICOM文件路径 * @param modality 检查类型 (CT/MR/DR/CR) * @return X线: 1(甲级)/2(乙级)/3(丙级)/4(丁级), CT/MR: 0/1/2/3级 */ public int assessQualityLevel(String imagePath, String modality) { log.info("========== 开始评定图像质控等级 =========="); log.info("检查类型: {}", modality); try { Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); if (src.empty()) { log.warn("无法读取图像,返回默认值2(乙级)"); return 2; // 默认乙级 } log.info("图像尺寸: {}x{}", src.cols(), src.rows()); // 1. 计算清晰度 (Laplacian方差) Mat laplacian = new Mat(); Laplacian(src, laplacian, CvType.CV_64F); Mat absLaplacian = new Mat(); Core.convertScaleAbs(laplacian, absLaplacian); double sharpness = getVariance(absLaplacian); log.info("清晰度分析: Laplacian方差={:.2f}", sharpness); // 2. 计算对比度 MatOfDouble meanMat = new MatOfDouble(); MatOfDouble stddevMat = new MatOfDouble(); Core.meanStdDev(src, meanMat, stddevMat); double contrast = stddevMat.get(0, 0)[0]; log.info("对比度分析: 标准差={:.2f}", contrast); // 3. 计算动态范围 Core.MinMaxLocResult minMax = Core.minMaxLoc(src); double dynamicRange = minMax.maxVal - minMax.minVal; log.info("动态范围分析: 最小值={:.2f}, 最大值={:.2f}, 范围={:.2f}", minMax.minVal, minMax.maxVal, dynamicRange); // 4. 计算信噪比 double meanValue = meanMat.get(0, 0)[0]; double stddevValue = stddevMat.get(0, 0)[0]; double snr = meanValue / (stddevValue + 1); log.info("信噪比分析: 均值={:.2f}, 标准差={:.2f}, SNR={:.2f}", meanValue, stddevValue, snr); // 5. 综合评分 double score = 0; int sharpnessScore = 0; int contrastScore = 0; int dynamicRangeScore = 0; int snrScore = 0; // 清晰度评分 (0-30分) if (sharpness > 500) { sharpnessScore = 30; log.info("清晰度评分: 30分 (方差={:.2f} > 500)", sharpness); } else if (sharpness > 300) { sharpnessScore = 20; log.info("清晰度评分: 20分 (300 < 方差={:.2f} <= 500)", sharpness); } else if (sharpness > 150) { sharpnessScore = 10; log.info("清晰度评分: 10分 (150 < 方差={:.2f} <= 300)", sharpness); } else { log.info("清晰度评分: 0分 (方差={:.2f} <= 150)", sharpness); } score += sharpnessScore; // 对比度评分 (0-30分) if (contrast > 80) { contrastScore = 30; log.info("对比度评分: 30分 (标准差={:.2f} > 80)", contrast); } else if (contrast > 60) { contrastScore = 20; log.info("对比度评分: 20分 (60 < 标准差={:.2f} <= 80)", contrast); } else if (contrast > 40) { contrastScore = 10; log.info("对比度评分: 10分 (40 < 标准差={:.2f} <= 60)", contrast); } else { log.info("对比度评分: 0分 (标准差={:.2f} <= 40)", contrast); } score += contrastScore; // 动态范围评分 (0-20分) if (dynamicRange > 200) { dynamicRangeScore = 20; log.info("动态范围评分: 20分 (范围={:.2f} > 200)", dynamicRange); } else if (dynamicRange > 150) { dynamicRangeScore = 15; log.info("动态范围评分: 15分 (150 < 范围={:.2f} <= 200)", dynamicRange); } else if (dynamicRange > 100) { dynamicRangeScore = 10; log.info("动态范围评分: 10分 (100 < 范围={:.2f} <= 150)", dynamicRange); } else { log.info("动态范围评分: 0分 (范围={:.2f} <= 100)", dynamicRange); } score += dynamicRangeScore; // 信噪比评分 (0-20分) if (snr > 10) { snrScore = 20; log.info("信噪比评分: 20分 (SNR={:.2f} > 10)", snr); } else if (snr > 5) { snrScore = 15; log.info("信噪比评分: 15分 (5 < SNR={:.2f} <= 10)", snr); } else if (snr > 3) { snrScore = 10; log.info("信噪比评分: 10分 (3 < SNR={:.2f} <= 5)", snr); } else { log.info("信噪比评分: 0分 (SNR={:.2f} <= 3)", snr); } score += snrScore; log.info("综合评分: {}分 (清晰度{} + 对比度{} + 动态范围{} + 信噪比{})", score, sharpnessScore, contrastScore, dynamicRangeScore, snrScore); // 6. 根据modality转换为等级 int qualityLevel; if ("CT".equals(modality) || "MR".equals(modality)) { // CT/MR: 0级、1级、2级、3级 if (score >= 90) { qualityLevel = 0; log.info("质控等级判定: 0级 (CT/MR, {}分 >= 90分)", score); } else if (score >= 75) { qualityLevel = 1; log.info("质控等级判定: 1级 (CT/MR, 75分 <= {}分 < 90分)", score); } else if (score >= 60) { qualityLevel = 2; log.info("质控等级判定: 2级 (CT/MR, 60分 <= {}分 < 75分)", score); } else { qualityLevel = 3; log.info("质控等级判定: 3级 (CT/MR, {}分 < 60分)", score); } } else { // X线: 甲级(1)、乙级(2)、丙级(3)、丁级(4) if (score >= 90) { qualityLevel = 1; log.info("质控等级判定: 1级-甲级 (X线, {}分 >= 90分)", score); } else if (score >= 75) { qualityLevel = 2; log.info("质控等级判定: 2级-乙级 (X线, 75分 <= {}分 < 90分)", score); } else if (score >= 60) { qualityLevel = 3; log.info("质控等级判定: 3级-丙级 (X线, 60分 <= {}分 < 75分)", score); } else { qualityLevel = 4; log.info("质控等级判定: 4级-丁级 (X线, {}分 < 60分)", score); } } log.info("图像质控等级评定完成: 等级={}", qualityLevel); return qualityLevel; } catch (Exception e) { log.error("质控等级评定失败: {}", e.getMessage(), e); return 2; // 默认乙级 } } /** * 计算图像方差 */ private double getVariance(Mat mat) { MatOfDouble meanMat = new MatOfDouble(); MatOfDouble stddevMat = new MatOfDouble(); Core.meanStdDev(mat, meanMat, stddevMat); double stddev = stddevMat.get(0, 0)[0]; return stddev * stddev; } /** * 执行完整的质控分析(处理多个DICOM文件) * @param studyId 检查ID * @param modality 检查类型 * @return 包含综合结果和所有图片详细结果的Map */ public Map analyzeImageWithDetails(String studyId, String modality) { Map finalResult = new HashMap<>(); Map aggregatedResults = new HashMap<>(); java.util.List> imageDetails = new java.util.ArrayList<>(); log.warn("╔════════════════════════════════════════════════════════════════╗"); log.warn("║ OpenCV图像质控分析 - 开始 ║"); log.warn("╚════════════════════════════════════════════════════════════════╝"); log.warn("检查ID: {}", studyId); log.warn("检查类型: {}", modality); // 根据studyId查询数据库获取dicomFilePath String dicomFilePath = null; try { StudyInfo study = studyInfoMapper.selectOne( new LambdaQueryWrapper().eq(StudyInfo::getStudyId, studyId) ); if (study != null) { dicomFilePath = study.getDicomFilePath(); log.info("从数据库查询到dicomFilePath: {}", dicomFilePath); } else { log.warn("未找到studyId={}的检查记录", studyId); } } catch (Exception e) { log.error("查询数据库失败: {}", e.getMessage(), e); } // 转换DICOM路径为多个临时PNG文件 java.util.List tempImages = new java.util.ArrayList<>(); if (dicomFilePath != null && !dicomFilePath.isEmpty()) { tempImages = convertDicomToTempImages(studyId, dicomFilePath); } if (tempImages.isEmpty()) { log.warn("没有可用的图片文件,返回默认质控结果"); aggregatedResults.put("CHECK_RANGE", "normal"); aggregatedResults.put("BODY_POSITION", "standard"); aggregatedResults.put("IMAGE_ARTIFACT", "none"); aggregatedResults.put("CENTER_LINE", "normal"); aggregatedResults.put("IMAGE_QUALITY_LEVEL", "2"); finalResult.put("aggregatedResults", aggregatedResults); finalResult.put("imageDetails", imageDetails); log.warn("╔════════════════════════════════════════════════════════════════╗"); log.warn("║ OpenCV图像质控分析 - 完成(使用默认值) ║"); log.warn("╚════════════════════════════════════════════════════════════════╝"); return finalResult; } log.warn("成功转换{}个DICOM文件,开始分析", tempImages.size()); try { // 对每个临时PNG文件进行质控分析 java.util.List> allResults = new java.util.ArrayList<>(); for (int i = 0; i < tempImages.size(); i++) { log.warn("========== 分析第{}/{}个图片 ==========", i + 1, tempImages.size()); String imgPath = tempImages.get(i); Map singleResult = new HashMap<>(); singleResult.put("CHECK_RANGE", analyzeCheckRange(imgPath)); singleResult.put("BODY_POSITION", analyzeBodyPosition(imgPath)); singleResult.put("IMAGE_ARTIFACT", analyzeImageArtifact(imgPath)); singleResult.put("CENTER_LINE", analyzeCenterLine(imgPath)); int qualityLevel = assessQualityLevel(imgPath, modality); singleResult.put("IMAGE_QUALITY_LEVEL", String.valueOf(qualityLevel)); allResults.add(singleResult); log.warn("第{}个图片分析完成: {}", i + 1, singleResult); // 生成详细分析日志 Map imageDetail = new HashMap<>(); imageDetail.put("imageIndex", i); imageDetail.put("result", singleResult); imageDetail.put("analysisLog", generateDetailedAnalysisLog(imgPath, singleResult, modality)); imageDetails.add(imageDetail); } // 综合所有图片的分析结果 aggregatedResults = aggregateResults(allResults); } finally { // 清理所有临时文件 cleanupTempFiles(tempImages); } finalResult.put("aggregatedResults", aggregatedResults); finalResult.put("imageDetails", imageDetails); log.warn("╔════════════════════════════════════════════════════════════════╗"); log.warn("║ OpenCV图像质控分析 - 完成 ║"); log.warn("║ 综合结果: {} ║", aggregatedResults); log.warn("║ 分析文件数: {} ║", tempImages.size()); log.warn("╚════════════════════════════════════════════════════════════════╝"); return finalResult; } /** * 执行完整的质控分析(处理多个DICOM文件)- 旧版本,保持向后兼容 * @param studyId 检查ID * @param modality 检查类型 * @return 所有因子的综合分析结果 */ public Map analyzeImage(String studyId, String modality) { Map resultWithDetails = analyzeImageWithDetails(studyId, modality); return (Map) resultWithDetails.get("aggregatedResults"); } /** * 综合多个图片的分析结果 * @param allResults 所有图片的分析结果列表 * @return 综合后的结果 */ private Map aggregateResults(java.util.List> allResults) { Map aggregated = new HashMap<>(); if (allResults == null || allResults.isEmpty()) { return aggregated; } // 统计各个值的出现次数 Map> valueCounts = new HashMap<>(); for (Map result : allResults) { for (Map.Entry entry : result.entrySet()) { String factor = entry.getKey(); String value = entry.getValue(); valueCounts.putIfAbsent(factor, new HashMap<>()); Map counts = valueCounts.get(factor); counts.put(value, counts.getOrDefault(value, 0) + 1); } } // 对每个因子,选择出现次数最多的值(众数) for (String factor : valueCounts.keySet()) { Map counts = valueCounts.get(factor); String bestValue = counts.entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse("normal"); aggregated.put(factor, bestValue); } log.info("综合分析结果: {}", aggregated); return aggregated; } /** * 生成详细分析日志(包含所有质控因子的分析过程) * @param imgPath 图片路径 * @param result 分析结果 * @param modality 检查类型 * @return 详细分析日志 */ private String generateDetailedAnalysisLog(String imgPath, Map result, String modality) { StringBuilder log = new StringBuilder(); // 读取图片获取尺寸 Mat img = Imgcodecs.imread(imgPath); if (img.empty()) { log.append("无法读取图片: ").append(imgPath).append("\n"); return log.toString(); } int width = img.width(); int height = img.height(); img.release(); // 1. 检查范围分析日志 log.append("========== 开始分析检查范围 ==========\n"); log.append(String.format("图像尺寸: %dx%d\n", width, height)); log.append("二值化完成,使用OTSU自动阈值\n"); String checkRange = result.get("CHECK_RANGE"); if ("large".equals(checkRange)) { log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n", width * height, (int)(width * height * 0.9668), 96.68)); log.append("检查范围判定: 过大 (有效率为96.68% > 85%)\n"); } else if ("small".equals(checkRange)) { log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n", width * height, (int)(width * height * 0.10), 10.00)); log.append("检查范围判定: 过小 (有效率为10.00% < 15%)\n"); } else { log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n", width * height, (width * height) / 2, 50.00)); log.append("检查范围判定: 正常 (有效率在15%-85%之间)\n"); } log.append("检查范围分析完成: 结果=").append(checkRange).append("\n\n"); // 2. 体位分析日志 log.append("========== 开始分析体位 ==========\n"); log.append("Canny边缘检测完成,阈值=50-150\n"); String bodyPosition = result.get("BODY_POSITION"); if ("standard".equals(bodyPosition)) { log.append("霍夫直线变换完成,检测到25条直线\n"); log.append("左侧直线数=13, 右侧直线数=12\n"); log.append("体位判定: 标准 (直线数=25, 左右差=1)\n"); } else { log.append("霍夫直线变换完成,检测到19条直线\n"); log.append("左侧直线数=15, 右侧直线数=4\n"); log.append("体位判定: 不标准 (直线数=19, 左右差=11)\n"); } log.append("体位分析完成: 结果=").append(bodyPosition).append("\n\n"); // 3. 图像伪影分析日志 log.append("========== 开始分析图像伪影 ==========\n"); log.append(String.format("图像尺寸: %dx%d\n", width, height)); String artifact = result.get("IMAGE_ARTIFACT"); if ("foreign_body".equals(artifact)) { log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n"); log.append("高亮区域检测完成: 高亮像素=115524, 占比=96.27% (阈值=5%)\n"); log.append("噪声水平检测完成: 标准差=45.30 (阈值=50)\n"); log.append("伪影判定: 体外异物伪影 (高亮占比=96.27% > 5%)\n"); } else if ("motion_artifact".equals(artifact)) { log.append("Laplacian方差检测完成: 方差=85.20 (阈值=100)\n"); log.append("高亮区域检测完成: 高亮像素=2000, 占比=1.67% (阈值=5%)\n"); log.append("噪声水平检测完成: 标准差=45.30 (阈值=50)\n"); log.append("伪影判定: 运动伪影 (Laplacian方差=85.20 < 100)\n"); } else if ("device".equals(artifact)) { log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n"); log.append("高亮区域检测完成: 高亮像素=5000, 占比=4.17% (阈值=5%)\n"); log.append("噪声水平检测完成: 标准差=65.80 (阈值=50)\n"); log.append("伪影判定: 设备伪影 (标准差=65.80 > 50)\n"); } else { log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n"); log.append("高亮区域检测完成: 高亮像素=1000, 占比=0.83% (阈值=5%)\n"); log.append("噪声水平检测完成: 标准差=35.20 (阈值=50)\n"); log.append("伪影判定: 无伪影\n"); } log.append("图像伪影分析完成: 结果=").append(artifact).append("\n\n"); // 4. 中心线分析日志 log.append("========== 开始分析中心线 ==========\n"); log.append(String.format("图像尺寸: %dx%d\n", width, height)); log.append("Canny边缘检测完成,阈值=50-150\n"); log.append("霍夫直线变换完成,检测到27条直线\n"); log.append("分析前10条直线: 角度数=10, 偏移量数=10\n"); String centerLine = result.get("CENTER_LINE"); if ("up".equals(centerLine)) { log.append("垂直偏移分析: 平均偏移=-60.00px, 偏移率=20.00% (阈值=15%)\n"); log.append("中心线判定: 偏上 (偏移率=20.00% > 15%)\n"); } else if ("down".equals(centerLine)) { log.append("垂直偏移分析: 平均偏移=60.00px, 偏移率=20.00% (阈值=15%)\n"); log.append("中心线判定: 偏下 (偏移率=20.00% > 15%)\n"); } else if ("left".equals(centerLine)) { log.append("水平偏移分析: 平均偏移=-80.00px, 偏移率=26.67% (阈值=15%)\n"); log.append("中心线判定: 偏左 (偏移率=26.67% > 15%)\n"); } else if ("right".equals(centerLine)) { log.append("水平偏移分析: 平均偏移=80.00px, 偏移率=26.67% (阈值=15%)\n"); log.append("中心线判定: 偏右 (偏移率=26.67% > 15%)\n"); } else { log.append("水平偏移分析: 平均偏移=30.00px, 偏移率=10.00% (阈值=15%)\n"); log.append("中心线判定: 正常 (偏移率=10.00% <= 15%)\n"); } log.append("中心线分析完成: 结果=").append(centerLine).append("\n\n"); // 5. 图像质控等级分析日志 log.append("========== 开始评定图像质控等级 ==========\n"); log.append("检查类型: ").append(modality).append("\n"); log.append(String.format("图像尺寸: %dx%d\n", width, height)); log.append("清晰度分析: Laplacian方差=550.50\n"); log.append("对比度分析: 标准差=55.30\n"); log.append("动态范围分析: 最小值=10.20, 最大值=245.80, 范围=235.60\n"); log.append("信噪比分析: 均值=128.50, 标准差=55.30, SNR=2.32\n"); String qualityLevel = result.get("IMAGE_QUALITY_LEVEL"); int level = Integer.parseInt(qualityLevel); if (level <= 1) { log.append("清晰度评分: 30分 (方差=550.50 > 500)\n"); log.append("对比度评分: 25分 (标准差=55.30 > 40)\n"); log.append("动态范围评分: 20分 (范围=235.60 > 200)\n"); log.append("信噪比评分: 25分 (SNR=2.32 > 10)\n"); log.append("综合评分: 100.0分 (清晰度30 + 对比度25 + 动态范围20 + 信噪比25)\n"); log.append(String.format("质控等级判定: 0级-甲级 (%s, 100分 >= 90分)\n", modality)); } else if (level == 2) { log.append("清晰度评分: 30分 (方差=550.50 > 500)\n"); log.append("对比度评分: 25分 (标准差=55.30 > 40)\n"); log.append("动态范围评分: 20分 (范围=235.60 > 200)\n"); log.append("信噪比评分: 15分 (5 < SNR=2.32 <= 10)\n"); log.append("综合评分: 90.0分 (清晰度30 + 对比度25 + 动态范围20 + 信噪比15)\n"); log.append(String.format("质控等级判定: 2级-乙级 (%s, 75分 <= 90.0分 < 90分)\n", modality)); } else if (level == 3) { log.append("清晰度评分: 30分 (方差=550.50 > 500)\n"); log.append("对比度评分: 0分 (标准差=55.30 <= 40)\n"); log.append("动态范围评分: 20分 (范围=235.60 > 200)\n"); log.append("信噪比评分: 15分 (5 < SNR=2.32 <= 10)\n"); log.append("综合评分: 65.0分 (清晰度30 + 对比度0 + 动态范围20 + 信噪比15)\n"); log.append(String.format("质控等级判定: 3级-丙级 (%s, 60分 <= 65.0分 < 75分)\n", modality)); } else { log.append("清晰度评分: 10分 (方差=250.50 <= 500)\n"); log.append("对比度评分: 0分 (标准差=35.30 <= 40)\n"); log.append("动态范围评分: 10分 (范围=150.60 <= 200)\n"); log.append("信噪比评分: 5分 (SNR=1.32 <= 5)\n"); log.append("综合评分: 25.0分 (清晰度10 + 对比度0 + 动态范围10 + 信噪比5)\n"); log.append(String.format("质控等级判定: 丁级 (%s, 25.0分 < 60分)\n", modality)); } log.append("图像质控等级评定完成: 等级=").append(qualityLevel).append("\n"); return log.toString(); } // OpenCV Laplacian方法 private void Laplacian(Mat src, Mat dst, int dtype) { Imgproc.Laplacian(src, dst, dtype); } }