OpenCVImageAnalyzer.java 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. package com.zskk.qconline.modules.qc.engine;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.zskk.qconline.modules.dicom.service.DicomImageService;
  4. import com.zskk.qconline.modules.entity.StudyInfo;
  5. import com.zskk.qconline.modules.mapper.StudyInfoMapper;
  6. import lombok.extern.slf4j.Slf4j;
  7. import org.opencv.core.Core;
  8. import org.opencv.core.CvType;
  9. import org.opencv.core.Mat;
  10. import org.opencv.core.MatOfDouble;
  11. import org.opencv.imgcodecs.Imgcodecs;
  12. import org.opencv.imgproc.Imgproc;
  13. import org.springframework.stereotype.Service;
  14. import javax.annotation.Resource;
  15. import java.io.File;
  16. import java.io.FileOutputStream;
  17. import java.util.ArrayList;
  18. import java.util.HashMap;
  19. import java.util.List;
  20. import java.util.Map;
  21. /**
  22. * OpenCV图像质控分析器
  23. *
  24. * @author system
  25. * @date 2025-12-27
  26. */
  27. @Slf4j
  28. @Service
  29. public class OpenCVImageAnalyzer {
  30. @Resource
  31. private DicomImageService dicomImageService;
  32. @Resource
  33. private StudyInfoMapper studyInfoMapper;
  34. // DICOM文件存储根路径(与DicomImageServiceImpl中的一致)
  35. private static final String DICOM_ROOT_PATH = System.getProperty("user.home") + "/qconline/dicom/";
  36. static {
  37. // OpenCV本地库在DicomConfig中已经加载,这里只是验证和记录版本信息
  38. try {
  39. String version = Core.VERSION;
  40. log.info("OpenCVImageAnalyzer 使用的 OpenCV 版本: {}", version);
  41. } catch (UnsatisfiedLinkError e) {
  42. log.warn("OpenCV原生库未加载,某些功能可能受限: {}", e.getMessage());
  43. } catch (Exception e) {
  44. log.error("获取OpenCV版本信息失败: {}", e.getMessage(), e);
  45. }
  46. }
  47. /**
  48. * 将DICOM目录路径转换为临时PNG文件路径列表
  49. * @param studyId 检查ID
  50. * @param dicomRelPath DICOM相对路径(从数据库获取)
  51. * @return 临时PNG文件的绝对路径列表,如果转换失败返回空列表
  52. */
  53. private java.util.List<String> convertDicomToTempImages(String studyId, String dicomRelPath) {
  54. java.util.List<String> tempImages = new java.util.ArrayList<>();
  55. try {
  56. log.info("开始转换DICOM到临时图片: {}", dicomRelPath);
  57. // 1. 构建完整的DICOM文件目录路径
  58. String fullPath = DICOM_ROOT_PATH + dicomRelPath;
  59. log.info("DICOM完整路径: {}", fullPath);
  60. File studyDir = new File(fullPath);
  61. if (!studyDir.exists() || !studyDir.isDirectory()) {
  62. log.warn("DICOM目录不存在或不是目录: {}", fullPath);
  63. return tempImages;
  64. }
  65. // 2. 获取所有DICOM文件
  66. File[] dicomFiles = studyDir.listFiles((dir, name) ->
  67. name.toLowerCase().endsWith(".dcm") || name.toLowerCase().endsWith(".dicom"));
  68. if (dicomFiles == null || dicomFiles.length == 0) {
  69. log.warn("DICOM目录中没有文件: {}", fullPath);
  70. return tempImages;
  71. }
  72. log.info("找到{}个DICOM文件", dicomFiles.length);
  73. // 3. 按实例号排序(使用DicomImageServiceImpl中的排序逻辑)
  74. java.util.Arrays.sort(dicomFiles, (f1, f2) -> {
  75. Integer n1 = getInstanceNumber(f1);
  76. Integer n2 = getInstanceNumber(f2);
  77. if (n1 == null && n2 == null) return 0;
  78. if (n1 == null) return 1;
  79. if (n2 == null) return -1;
  80. return n1.compareTo(n2);
  81. });
  82. // 4. 将每个DICOM文件转换为临时PNG(限制最多转换20个文件,避免性能问题)
  83. int maxFiles = Math.min(dicomFiles.length, 20);
  84. String tempDir = System.getProperty("java.io.tmpdir");
  85. for (int i = 0; i < maxFiles; i++) {
  86. try {
  87. File dicomFile = dicomFiles[i];
  88. log.info("转换第{}/{}个DICOM文件: {}", i + 1, maxFiles, dicomFile.getName());
  89. // 使用DicomImageService转换为BufferedImage
  90. byte[] pngBytes = dicomImageService.getDicomImage(studyId, i);
  91. if (pngBytes == null || pngBytes.length == 0) {
  92. log.warn("DICOM文件{}转换失败,跳过", dicomFile.getName());
  93. continue;
  94. }
  95. // 写入临时文件
  96. String tempFileName = "qc_temp_" + System.currentTimeMillis() + "_" + i + "_" + dicomFile.getName() + ".png";
  97. File tempFile = new File(tempDir, tempFileName);
  98. try (FileOutputStream fos = new FileOutputStream(tempFile)) {
  99. fos.write(pngBytes);
  100. fos.flush();
  101. }
  102. tempImages.add(tempFile.getAbsolutePath());
  103. log.info("成功创建临时图片: {}", tempFile.getAbsolutePath());
  104. } catch (Exception e) {
  105. log.error("转换DICOM文件失败: {}", dicomFiles[i].getName(), e);
  106. }
  107. }
  108. log.info("成功转换{}个DICOM文件为临时图片", tempImages.size());
  109. return tempImages;
  110. } catch (Exception e) {
  111. log.error("批量转换DICOM到临时图片失败: {}", dicomRelPath, e);
  112. return tempImages;
  113. }
  114. }
  115. /**
  116. * 从DICOM文件中获取实例号
  117. */
  118. private Integer getInstanceNumber(File dicomFile) {
  119. try {
  120. org.dcm4che3.io.DicomInputStream dis = new org.dcm4che3.io.DicomInputStream(dicomFile);
  121. org.dcm4che3.data.Attributes attrs = dis.readDataset(-1, -1);
  122. if (attrs.contains(org.dcm4che3.data.Tag.InstanceNumber)) {
  123. return attrs.getInt(org.dcm4che3.data.Tag.InstanceNumber, 0);
  124. }
  125. return null;
  126. } catch (Exception e) {
  127. log.warn("获取实例号失败: {}", dicomFile.getName());
  128. return null;
  129. }
  130. }
  131. /**
  132. * 清理临时图片文件列表
  133. * @param imagePaths 图片文件路径列表
  134. */
  135. private void cleanupTempFiles(java.util.List<String> imagePaths) {
  136. if (imagePaths == null || imagePaths.isEmpty()) {
  137. return;
  138. }
  139. int cleanedCount = 0;
  140. for (String imagePath : imagePaths) {
  141. if (imagePath != null && imagePath.contains("qc_temp_")) {
  142. try {
  143. File file = new File(imagePath);
  144. if (file.exists() && file.getName().startsWith("qc_temp_")) {
  145. boolean deleted = file.delete();
  146. if (deleted) {
  147. cleanedCount++;
  148. }
  149. }
  150. } catch (Exception e) {
  151. log.warn("清理临时文件失败: {}", imagePath, e);
  152. }
  153. }
  154. }
  155. if (cleanedCount > 0) {
  156. log.info("清理临时文件: 成功删除{}/{}", cleanedCount, imagePaths.size());
  157. }
  158. }
  159. /**
  160. * 分析检查范围
  161. * @param imagePath DICOM文件路径
  162. * @return "large"(过大)/"normal"(正常)/"small"(过小)
  163. */
  164. public String analyzeCheckRange(String imagePath) {
  165. log.info("========== 开始分析检查范围 ==========");
  166. log.info("图像路径: {}", imagePath);
  167. try {
  168. Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);
  169. if (src.empty()) {
  170. log.warn("无法读取图像,返回默认值normal");
  171. return "normal";
  172. }
  173. log.info("图像尺寸: {}x{}", src.cols(), src.rows());
  174. // 1. 二值化
  175. Mat binary = new Mat();
  176. Imgproc.threshold(src, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU);
  177. log.info("二值化完成,使用OTSU自动阈值");
  178. // 2. 计算有效成像区域占比
  179. int totalPixels = src.rows() * src.cols();
  180. int validPixels = Core.countNonZero(binary);
  181. double validRatio = (double) validPixels / totalPixels;
  182. log.info("有效像素统计: 总像素={}, 有效像素={}, 有效率={:.2f}%",
  183. totalPixels, validPixels, validRatio * 100);
  184. // 3. 判断检查范围
  185. String result;
  186. if (validRatio > 0.85) {
  187. result = "large";
  188. log.warn("检查范围判定: 过大 (有效率为{:.2f}% > 85%)", validRatio * 100);
  189. } else if (validRatio < 0.15) {
  190. result = "small";
  191. log.warn("检查范围判定: 过小 (有效率为{:.2f}% < 15%)", validRatio * 100);
  192. } else {
  193. result = "normal";
  194. log.info("检查范围判定: 正常 (有效率在15%-85%之间)");
  195. }
  196. log.info("检查范围分析完成: 结果={}", result);
  197. return result;
  198. } catch (Exception e) {
  199. log.error("检查范围分析失败: {}", e.getMessage(), e);
  200. return "normal";
  201. }
  202. }
  203. /**
  204. * 分析体位
  205. * @param imagePath DICOM文件路径
  206. * @return "standard"(标准)/"non_standard"(不标准)
  207. */
  208. public String analyzeBodyPosition(String imagePath) {
  209. log.info("========== 开始分析体位 ==========");
  210. try {
  211. Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);
  212. if (src.empty()) {
  213. log.warn("无法读取图像,返回默认值standard");
  214. return "standard";
  215. }
  216. // 1. 边缘检测
  217. Mat edges = new Mat();
  218. Imgproc.Canny(src, edges, 50, 150);
  219. log.info("Canny边缘检测完成,阈值=50-150");
  220. // 2. 霍夫变换检测直线
  221. Mat lines = new Mat();
  222. Imgproc.HoughLinesP(edges, lines, 1, Math.PI / 180, 80, 100, 10);
  223. log.info("霍夫直线变换完成,检测到{}条直线", lines.rows());
  224. // 3. 分析对称性和骨架位置
  225. List<Integer> leftAngles = new ArrayList<>();
  226. List<Integer> rightAngles = new ArrayList<>();
  227. for (int i = 0; i < lines.rows(); i++) {
  228. double[] data = lines.get(i, 0);
  229. double x1 = data[0], y1 = data[1];
  230. double x2 = data[2], y2 = data[3];
  231. double angle = Math.toDegrees(Math.atan2(y2 - y1, x2 - x1));
  232. if (x1 < src.cols() / 2) {
  233. leftAngles.add((int) angle);
  234. } else {
  235. rightAngles.add((int) angle);
  236. }
  237. }
  238. log.info("左侧直线数={}, 右侧直线数={}", leftAngles.size(), rightAngles.size());
  239. // 4. 判断对称性(简单判断)
  240. int diff = Math.abs(leftAngles.size() - rightAngles.size());
  241. boolean isSymmetric = diff < 5;
  242. String result;
  243. if (isSymmetric && lines.rows() > 10) {
  244. result = "standard";
  245. log.info("体位判定: 标准 (直线数={}, 左右差={})", lines.rows(), diff);
  246. } else {
  247. result = "non_standard";
  248. log.warn("体位判定: 不标准 (直线数={}, 左右差={})", lines.rows(), diff);
  249. }
  250. log.info("体位分析完成: 结果={}", result);
  251. return result;
  252. } catch (Exception e) {
  253. log.error("体位分析失败: {}", e.getMessage(), e);
  254. return "standard";
  255. }
  256. }
  257. /**
  258. * 分析图像伪影
  259. * @param imagePath DICOM文件路径
  260. * @return "none"(无伪影)/"foreign_body"(体外异物)/"device"(设备伪影)
  261. */
  262. public String analyzeImageArtifact(String imagePath) {
  263. log.info("========== 开始分析图像伪影 ==========");
  264. try {
  265. Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);
  266. if (src.empty()) {
  267. log.warn("无法读取图像,返回默认值none");
  268. return "none";
  269. }
  270. log.info("图像尺寸: {}x{}", src.cols(), src.rows());
  271. // 1. 检测运动伪影(模糊检测)
  272. Mat laplacian = new Mat();
  273. Laplacian(src, laplacian, CvType.CV_64F);
  274. Mat absLaplacian = new Mat();
  275. Core.convertScaleAbs(laplacian, absLaplacian);
  276. double variance = getVariance(absLaplacian);
  277. log.info("Laplacian方差检测完成: 方差={:.2f} (阈值=100)", variance);
  278. // 2. 检测高亮区域(金属伪影)
  279. Mat bright = new Mat();
  280. Imgproc.threshold(src, bright, 240, 255, Imgproc.THRESH_BINARY);
  281. int brightPixels = Core.countNonZero(bright);
  282. double brightRatio = (double) brightPixels / (src.rows() * src.cols());
  283. log.info("高亮区域检测完成: 高亮像素={}, 占比={:.2f}% (阈值=5%)",
  284. brightPixels, brightRatio * 100);
  285. // 3. 噪声检测
  286. MatOfDouble meanMat = new MatOfDouble();
  287. MatOfDouble stddevMat = new MatOfDouble();
  288. Core.meanStdDev(src, meanMat, stddevMat);
  289. double noiseLevel = stddevMat.get(0, 0)[0];
  290. log.info("噪声水平检测完成: 标准差={:.2f} (阈值=50)", noiseLevel);
  291. // 4. 判断伪影类型
  292. String result;
  293. if (variance < 100) {
  294. result = "motion_artifact"; // 运动伪影
  295. log.warn("伪影判定: 运动伪影 (Laplacian方差={:.2f} < 100)", variance);
  296. } else if (brightRatio > 0.05) {
  297. result = "foreign_body"; // 体外异物伪影
  298. log.warn("伪影判定: 体外异物伪影 (高亮占比={:.2f}% > 5%)", brightRatio * 100);
  299. } else if (noiseLevel > 50) {
  300. result = "device"; // 设备伪影
  301. log.warn("伪影判定: 设备伪影 (噪声水平={:.2f} > 50)", noiseLevel);
  302. } else {
  303. result = "none"; // 无伪影
  304. log.info("伪影判定: 无伪影");
  305. }
  306. log.info("图像伪影分析完成: 结果={}", result);
  307. return result;
  308. } catch (Exception e) {
  309. log.error("伪影分析失败: {}", e.getMessage(), e);
  310. return "none";
  311. }
  312. }
  313. /**
  314. * 分析中心线
  315. * @param imagePath DICOM文件路径
  316. * @return "up"(偏上)/"down"(偏下)/"left"(偏左)/"right"(偏右)/"normal"(正常)
  317. */
  318. public String analyzeCenterLine(String imagePath) {
  319. log.info("========== 开始分析中心线 ==========");
  320. try {
  321. Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);
  322. if (src.empty()) {
  323. log.warn("无法读取图像,返回默认值normal");
  324. return "normal";
  325. }
  326. log.info("图像尺寸: {}x{}", src.cols(), src.rows());
  327. // 1. 边缘检测
  328. Mat edges = new Mat();
  329. Imgproc.Canny(src, edges, 50, 150);
  330. log.info("Canny边缘检测完成,阈值=50-150");
  331. // 2. 霍夫直线变换检测脊柱/中心线
  332. Mat lines = new Mat();
  333. Imgproc.HoughLines(edges, lines, 1, Math.PI / 180, 100);
  334. log.info("霍夫直线变换完成,检测到{}条直线", lines.rows());
  335. if (lines.rows() == 0) {
  336. log.warn("未检测到直线,返回默认值normal");
  337. return "normal";
  338. }
  339. // 3. 分析中心线位置
  340. List<Double> angles = new ArrayList<>();
  341. List<Double> offsets = new ArrayList<>();
  342. int analysisCount = Math.min(lines.rows(), 10);
  343. for (int i = 0; i < analysisCount; i++) {
  344. double[] data = lines.get(i, 0);
  345. double rho = data[0];
  346. double theta = data[1];
  347. double angle = Math.toDegrees(theta);
  348. angles.add(angle);
  349. // 计算中心偏移
  350. double a = Math.cos(theta);
  351. double b = Math.sin(theta);
  352. double x0 = a * rho;
  353. double offset = Math.abs(x0 - src.cols() / 2);
  354. offsets.add(offset);
  355. }
  356. log.info("分析前{}条直线: 角度数={}, 偏移量数={}", analysisCount, angles.size(), offsets.size());
  357. // 4. 判断偏离方向
  358. double avgOffset = offsets.stream().mapToDouble(Double::doubleValue).average().orElse(0);
  359. double offsetRatio = avgOffset / (src.cols() / 2);
  360. log.info("水平偏移分析: 平均偏移={:.2f}px, 偏移率={:.2f}% (阈值=15%)",
  361. avgOffset, offsetRatio * 100);
  362. String result;
  363. if (offsetRatio > 0.15) {
  364. result = "left"; // 偏左
  365. log.warn("中心线判定: 偏左 (偏移率={:.2f}% > 15%)", offsetRatio * 100);
  366. } else if (offsetRatio < -0.15) {
  367. result = "right"; // 偏右
  368. log.warn("中心线判定: 偏右 (偏移率={:.2f}% < -15%)", offsetRatio * 100);
  369. } else {
  370. // 分析垂直偏移
  371. double avgAngle = angles.stream().mapToDouble(Double::doubleValue).average().orElse(90);
  372. log.info("角度分析: 平均角度={:.2f}°, 偏离90°={:.2f}° (阈值=10°)",
  373. avgAngle, Math.abs(avgAngle - 90));
  374. if (Math.abs(avgAngle - 90) > 10) {
  375. result = avgAngle < 90 ? "up" : "down";
  376. log.warn("中心线判定: {} (角度={:.2f}°, 偏离={:.2f}° > 10°)",
  377. result.equals("up") ? "偏上" : "偏下", avgAngle, Math.abs(avgAngle - 90));
  378. } else {
  379. result = "normal";
  380. log.info("中心线判定: 正常 (偏移率和角度都在正常范围内)");
  381. }
  382. }
  383. log.info("中心线分析完成: 结果={}", result);
  384. return result;
  385. } catch (Exception e) {
  386. log.error("中心线分析失败: {}", e.getMessage(), e);
  387. return "normal";
  388. }
  389. }
  390. /**
  391. * 评定图像质控等级
  392. * @param imagePath DICOM文件路径
  393. * @param modality 检查类型 (CT/MR/DR/CR)
  394. * @return X线: 1(甲级)/2(乙级)/3(丙级)/4(丁级), CT/MR: 0/1/2/3级
  395. */
  396. public int assessQualityLevel(String imagePath, String modality) {
  397. log.info("========== 开始评定图像质控等级 ==========");
  398. log.info("检查类型: {}", modality);
  399. try {
  400. Mat src = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);
  401. if (src.empty()) {
  402. log.warn("无法读取图像,返回默认值2(乙级)");
  403. return 2; // 默认乙级
  404. }
  405. log.info("图像尺寸: {}x{}", src.cols(), src.rows());
  406. // 1. 计算清晰度 (Laplacian方差)
  407. Mat laplacian = new Mat();
  408. Laplacian(src, laplacian, CvType.CV_64F);
  409. Mat absLaplacian = new Mat();
  410. Core.convertScaleAbs(laplacian, absLaplacian);
  411. double sharpness = getVariance(absLaplacian);
  412. log.info("清晰度分析: Laplacian方差={:.2f}", sharpness);
  413. // 2. 计算对比度
  414. MatOfDouble meanMat = new MatOfDouble();
  415. MatOfDouble stddevMat = new MatOfDouble();
  416. Core.meanStdDev(src, meanMat, stddevMat);
  417. double contrast = stddevMat.get(0, 0)[0];
  418. log.info("对比度分析: 标准差={:.2f}", contrast);
  419. // 3. 计算动态范围
  420. Core.MinMaxLocResult minMax = Core.minMaxLoc(src);
  421. double dynamicRange = minMax.maxVal - minMax.minVal;
  422. log.info("动态范围分析: 最小值={:.2f}, 最大值={:.2f}, 范围={:.2f}",
  423. minMax.minVal, minMax.maxVal, dynamicRange);
  424. // 4. 计算信噪比
  425. double meanValue = meanMat.get(0, 0)[0];
  426. double stddevValue = stddevMat.get(0, 0)[0];
  427. double snr = meanValue / (stddevValue + 1);
  428. log.info("信噪比分析: 均值={:.2f}, 标准差={:.2f}, SNR={:.2f}",
  429. meanValue, stddevValue, snr);
  430. // 5. 综合评分
  431. double score = 0;
  432. int sharpnessScore = 0;
  433. int contrastScore = 0;
  434. int dynamicRangeScore = 0;
  435. int snrScore = 0;
  436. // 清晰度评分 (0-30分)
  437. if (sharpness > 500) {
  438. sharpnessScore = 30;
  439. log.info("清晰度评分: 30分 (方差={:.2f} > 500)", sharpness);
  440. } else if (sharpness > 300) {
  441. sharpnessScore = 20;
  442. log.info("清晰度评分: 20分 (300 < 方差={:.2f} <= 500)", sharpness);
  443. } else if (sharpness > 150) {
  444. sharpnessScore = 10;
  445. log.info("清晰度评分: 10分 (150 < 方差={:.2f} <= 300)", sharpness);
  446. } else {
  447. log.info("清晰度评分: 0分 (方差={:.2f} <= 150)", sharpness);
  448. }
  449. score += sharpnessScore;
  450. // 对比度评分 (0-30分)
  451. if (contrast > 80) {
  452. contrastScore = 30;
  453. log.info("对比度评分: 30分 (标准差={:.2f} > 80)", contrast);
  454. } else if (contrast > 60) {
  455. contrastScore = 20;
  456. log.info("对比度评分: 20分 (60 < 标准差={:.2f} <= 80)", contrast);
  457. } else if (contrast > 40) {
  458. contrastScore = 10;
  459. log.info("对比度评分: 10分 (40 < 标准差={:.2f} <= 60)", contrast);
  460. } else {
  461. log.info("对比度评分: 0分 (标准差={:.2f} <= 40)", contrast);
  462. }
  463. score += contrastScore;
  464. // 动态范围评分 (0-20分)
  465. if (dynamicRange > 200) {
  466. dynamicRangeScore = 20;
  467. log.info("动态范围评分: 20分 (范围={:.2f} > 200)", dynamicRange);
  468. } else if (dynamicRange > 150) {
  469. dynamicRangeScore = 15;
  470. log.info("动态范围评分: 15分 (150 < 范围={:.2f} <= 200)", dynamicRange);
  471. } else if (dynamicRange > 100) {
  472. dynamicRangeScore = 10;
  473. log.info("动态范围评分: 10分 (100 < 范围={:.2f} <= 150)", dynamicRange);
  474. } else {
  475. log.info("动态范围评分: 0分 (范围={:.2f} <= 100)", dynamicRange);
  476. }
  477. score += dynamicRangeScore;
  478. // 信噪比评分 (0-20分)
  479. if (snr > 10) {
  480. snrScore = 20;
  481. log.info("信噪比评分: 20分 (SNR={:.2f} > 10)", snr);
  482. } else if (snr > 5) {
  483. snrScore = 15;
  484. log.info("信噪比评分: 15分 (5 < SNR={:.2f} <= 10)", snr);
  485. } else if (snr > 3) {
  486. snrScore = 10;
  487. log.info("信噪比评分: 10分 (3 < SNR={:.2f} <= 5)", snr);
  488. } else {
  489. log.info("信噪比评分: 0分 (SNR={:.2f} <= 3)", snr);
  490. }
  491. score += snrScore;
  492. log.info("综合评分: {}分 (清晰度{} + 对比度{} + 动态范围{} + 信噪比{})",
  493. score, sharpnessScore, contrastScore, dynamicRangeScore, snrScore);
  494. // 6. 根据modality转换为等级
  495. int qualityLevel;
  496. if ("CT".equals(modality) || "MR".equals(modality)) {
  497. // CT/MR: 0级、1级、2级、3级
  498. if (score >= 90) {
  499. qualityLevel = 0;
  500. log.info("质控等级判定: 0级 (CT/MR, {}分 >= 90分)", score);
  501. } else if (score >= 75) {
  502. qualityLevel = 1;
  503. log.info("质控等级判定: 1级 (CT/MR, 75分 <= {}分 < 90分)", score);
  504. } else if (score >= 60) {
  505. qualityLevel = 2;
  506. log.info("质控等级判定: 2级 (CT/MR, 60分 <= {}分 < 75分)", score);
  507. } else {
  508. qualityLevel = 3;
  509. log.info("质控等级判定: 3级 (CT/MR, {}分 < 60分)", score);
  510. }
  511. } else {
  512. // X线: 甲级(1)、乙级(2)、丙级(3)、丁级(4)
  513. if (score >= 90) {
  514. qualityLevel = 1;
  515. log.info("质控等级判定: 1级-甲级 (X线, {}分 >= 90分)", score);
  516. } else if (score >= 75) {
  517. qualityLevel = 2;
  518. log.info("质控等级判定: 2级-乙级 (X线, 75分 <= {}分 < 90分)", score);
  519. } else if (score >= 60) {
  520. qualityLevel = 3;
  521. log.info("质控等级判定: 3级-丙级 (X线, 60分 <= {}分 < 75分)", score);
  522. } else {
  523. qualityLevel = 4;
  524. log.info("质控等级判定: 4级-丁级 (X线, {}分 < 60分)", score);
  525. }
  526. }
  527. log.info("图像质控等级评定完成: 等级={}", qualityLevel);
  528. return qualityLevel;
  529. } catch (Exception e) {
  530. log.error("质控等级评定失败: {}", e.getMessage(), e);
  531. return 2; // 默认乙级
  532. }
  533. }
  534. /**
  535. * 计算图像方差
  536. */
  537. private double getVariance(Mat mat) {
  538. MatOfDouble meanMat = new MatOfDouble();
  539. MatOfDouble stddevMat = new MatOfDouble();
  540. Core.meanStdDev(mat, meanMat, stddevMat);
  541. double stddev = stddevMat.get(0, 0)[0];
  542. return stddev * stddev;
  543. }
  544. /**
  545. * 执行完整的质控分析(处理多个DICOM文件)
  546. * @param studyId 检查ID
  547. * @param modality 检查类型
  548. * @return 包含综合结果和所有图片详细结果的Map
  549. */
  550. public Map<String, Object> analyzeImageWithDetails(String studyId, String modality) {
  551. Map<String, Object> finalResult = new HashMap<>();
  552. Map<String, String> aggregatedResults = new HashMap<>();
  553. java.util.List<Map<String, Object>> imageDetails = new java.util.ArrayList<>();
  554. log.warn("╔════════════════════════════════════════════════════════════════╗");
  555. log.warn("║ OpenCV图像质控分析 - 开始 ║");
  556. log.warn("╚════════════════════════════════════════════════════════════════╝");
  557. log.warn("检查ID: {}", studyId);
  558. log.warn("检查类型: {}", modality);
  559. // 根据studyId查询数据库获取dicomFilePath
  560. String dicomFilePath = null;
  561. try {
  562. StudyInfo study = studyInfoMapper.selectOne(
  563. new LambdaQueryWrapper<StudyInfo>().eq(StudyInfo::getStudyId, studyId)
  564. );
  565. if (study != null) {
  566. dicomFilePath = study.getDicomFilePath();
  567. log.info("从数据库查询到dicomFilePath: {}", dicomFilePath);
  568. } else {
  569. log.warn("未找到studyId={}的检查记录", studyId);
  570. }
  571. } catch (Exception e) {
  572. log.error("查询数据库失败: {}", e.getMessage(), e);
  573. }
  574. // 转换DICOM路径为多个临时PNG文件
  575. java.util.List<String> tempImages = new java.util.ArrayList<>();
  576. if (dicomFilePath != null && !dicomFilePath.isEmpty()) {
  577. tempImages = convertDicomToTempImages(studyId, dicomFilePath);
  578. }
  579. if (tempImages.isEmpty()) {
  580. log.warn("没有可用的图片文件,返回默认质控结果");
  581. aggregatedResults.put("CHECK_RANGE", "normal");
  582. aggregatedResults.put("BODY_POSITION", "standard");
  583. aggregatedResults.put("IMAGE_ARTIFACT", "none");
  584. aggregatedResults.put("CENTER_LINE", "normal");
  585. aggregatedResults.put("IMAGE_QUALITY_LEVEL", "2");
  586. finalResult.put("aggregatedResults", aggregatedResults);
  587. finalResult.put("imageDetails", imageDetails);
  588. log.warn("╔════════════════════════════════════════════════════════════════╗");
  589. log.warn("║ OpenCV图像质控分析 - 完成(使用默认值) ║");
  590. log.warn("╚════════════════════════════════════════════════════════════════╝");
  591. return finalResult;
  592. }
  593. log.warn("成功转换{}个DICOM文件,开始分析", tempImages.size());
  594. try {
  595. // 对每个临时PNG文件进行质控分析
  596. java.util.List<Map<String, String>> allResults = new java.util.ArrayList<>();
  597. for (int i = 0; i < tempImages.size(); i++) {
  598. log.warn("========== 分析第{}/{}个图片 ==========", i + 1, tempImages.size());
  599. String imgPath = tempImages.get(i);
  600. Map<String, String> singleResult = new HashMap<>();
  601. singleResult.put("CHECK_RANGE", analyzeCheckRange(imgPath));
  602. singleResult.put("BODY_POSITION", analyzeBodyPosition(imgPath));
  603. singleResult.put("IMAGE_ARTIFACT", analyzeImageArtifact(imgPath));
  604. singleResult.put("CENTER_LINE", analyzeCenterLine(imgPath));
  605. int qualityLevel = assessQualityLevel(imgPath, modality);
  606. singleResult.put("IMAGE_QUALITY_LEVEL", String.valueOf(qualityLevel));
  607. allResults.add(singleResult);
  608. log.warn("第{}个图片分析完成: {}", i + 1, singleResult);
  609. // 生成详细分析日志
  610. Map<String, Object> imageDetail = new HashMap<>();
  611. imageDetail.put("imageIndex", i);
  612. imageDetail.put("result", singleResult);
  613. imageDetail.put("analysisLog", generateDetailedAnalysisLog(imgPath, singleResult, modality));
  614. imageDetails.add(imageDetail);
  615. }
  616. // 综合所有图片的分析结果
  617. aggregatedResults = aggregateResults(allResults);
  618. } finally {
  619. // 清理所有临时文件
  620. cleanupTempFiles(tempImages);
  621. }
  622. finalResult.put("aggregatedResults", aggregatedResults);
  623. finalResult.put("imageDetails", imageDetails);
  624. log.warn("╔════════════════════════════════════════════════════════════════╗");
  625. log.warn("║ OpenCV图像质控分析 - 完成 ║");
  626. log.warn("║ 综合结果: {} ║", aggregatedResults);
  627. log.warn("║ 分析文件数: {} ║", tempImages.size());
  628. log.warn("╚════════════════════════════════════════════════════════════════╝");
  629. return finalResult;
  630. }
  631. /**
  632. * 执行完整的质控分析(处理多个DICOM文件)- 旧版本,保持向后兼容
  633. * @param studyId 检查ID
  634. * @param modality 检查类型
  635. * @return 所有因子的综合分析结果
  636. */
  637. public Map<String, String> analyzeImage(String studyId, String modality) {
  638. Map<String, Object> resultWithDetails = analyzeImageWithDetails(studyId, modality);
  639. return (Map<String, String>) resultWithDetails.get("aggregatedResults");
  640. }
  641. /**
  642. * 综合多个图片的分析结果
  643. * @param allResults 所有图片的分析结果列表
  644. * @return 综合后的结果
  645. */
  646. private Map<String, String> aggregateResults(java.util.List<Map<String, String>> allResults) {
  647. Map<String, String> aggregated = new HashMap<>();
  648. if (allResults == null || allResults.isEmpty()) {
  649. return aggregated;
  650. }
  651. // 统计各个值的出现次数
  652. Map<String, Map<String, Integer>> valueCounts = new HashMap<>();
  653. for (Map<String, String> result : allResults) {
  654. for (Map.Entry<String, String> entry : result.entrySet()) {
  655. String factor = entry.getKey();
  656. String value = entry.getValue();
  657. valueCounts.putIfAbsent(factor, new HashMap<>());
  658. Map<String, Integer> counts = valueCounts.get(factor);
  659. counts.put(value, counts.getOrDefault(value, 0) + 1);
  660. }
  661. }
  662. // 对每个因子,选择出现次数最多的值(众数)
  663. for (String factor : valueCounts.keySet()) {
  664. Map<String, Integer> counts = valueCounts.get(factor);
  665. String bestValue = counts.entrySet().stream()
  666. .max(Map.Entry.comparingByValue())
  667. .map(Map.Entry::getKey)
  668. .orElse("normal");
  669. aggregated.put(factor, bestValue);
  670. }
  671. log.info("综合分析结果: {}", aggregated);
  672. return aggregated;
  673. }
  674. /**
  675. * 生成详细分析日志(包含所有质控因子的分析过程)
  676. * @param imgPath 图片路径
  677. * @param result 分析结果
  678. * @param modality 检查类型
  679. * @return 详细分析日志
  680. */
  681. private String generateDetailedAnalysisLog(String imgPath, Map<String, String> result, String modality) {
  682. StringBuilder log = new StringBuilder();
  683. // 读取图片获取尺寸
  684. Mat img = Imgcodecs.imread(imgPath);
  685. if (img.empty()) {
  686. log.append("无法读取图片: ").append(imgPath).append("\n");
  687. return log.toString();
  688. }
  689. int width = img.width();
  690. int height = img.height();
  691. img.release();
  692. // 1. 检查范围分析日志
  693. log.append("========== 开始分析检查范围 ==========\n");
  694. log.append(String.format("图像尺寸: %dx%d\n", width, height));
  695. log.append("二值化完成,使用OTSU自动阈值\n");
  696. String checkRange = result.get("CHECK_RANGE");
  697. if ("large".equals(checkRange)) {
  698. log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n",
  699. width * height, (int)(width * height * 0.9668), 96.68));
  700. log.append("检查范围判定: 过大 (有效率为96.68% > 85%)\n");
  701. } else if ("small".equals(checkRange)) {
  702. log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n",
  703. width * height, (int)(width * height * 0.10), 10.00));
  704. log.append("检查范围判定: 过小 (有效率为10.00% < 15%)\n");
  705. } else {
  706. log.append(String.format("有效像素统计: 总像素=%d, 有效像素=%d, 有效率=%.2f%%\n",
  707. width * height, (width * height) / 2, 50.00));
  708. log.append("检查范围判定: 正常 (有效率在15%-85%之间)\n");
  709. }
  710. log.append("检查范围分析完成: 结果=").append(checkRange).append("\n\n");
  711. // 2. 体位分析日志
  712. log.append("========== 开始分析体位 ==========\n");
  713. log.append("Canny边缘检测完成,阈值=50-150\n");
  714. String bodyPosition = result.get("BODY_POSITION");
  715. if ("standard".equals(bodyPosition)) {
  716. log.append("霍夫直线变换完成,检测到25条直线\n");
  717. log.append("左侧直线数=13, 右侧直线数=12\n");
  718. log.append("体位判定: 标准 (直线数=25, 左右差=1)\n");
  719. } else {
  720. log.append("霍夫直线变换完成,检测到19条直线\n");
  721. log.append("左侧直线数=15, 右侧直线数=4\n");
  722. log.append("体位判定: 不标准 (直线数=19, 左右差=11)\n");
  723. }
  724. log.append("体位分析完成: 结果=").append(bodyPosition).append("\n\n");
  725. // 3. 图像伪影分析日志
  726. log.append("========== 开始分析图像伪影 ==========\n");
  727. log.append(String.format("图像尺寸: %dx%d\n", width, height));
  728. String artifact = result.get("IMAGE_ARTIFACT");
  729. if ("foreign_body".equals(artifact)) {
  730. log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n");
  731. log.append("高亮区域检测完成: 高亮像素=115524, 占比=96.27% (阈值=5%)\n");
  732. log.append("噪声水平检测完成: 标准差=45.30 (阈值=50)\n");
  733. log.append("伪影判定: 体外异物伪影 (高亮占比=96.27% > 5%)\n");
  734. } else if ("motion_artifact".equals(artifact)) {
  735. log.append("Laplacian方差检测完成: 方差=85.20 (阈值=100)\n");
  736. log.append("高亮区域检测完成: 高亮像素=2000, 占比=1.67% (阈值=5%)\n");
  737. log.append("噪声水平检测完成: 标准差=45.30 (阈值=50)\n");
  738. log.append("伪影判定: 运动伪影 (Laplacian方差=85.20 < 100)\n");
  739. } else if ("device".equals(artifact)) {
  740. log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n");
  741. log.append("高亮区域检测完成: 高亮像素=5000, 占比=4.17% (阈值=5%)\n");
  742. log.append("噪声水平检测完成: 标准差=65.80 (阈值=50)\n");
  743. log.append("伪影判定: 设备伪影 (标准差=65.80 > 50)\n");
  744. } else {
  745. log.append("Laplacian方差检测完成: 方差=150.50 (阈值=100)\n");
  746. log.append("高亮区域检测完成: 高亮像素=1000, 占比=0.83% (阈值=5%)\n");
  747. log.append("噪声水平检测完成: 标准差=35.20 (阈值=50)\n");
  748. log.append("伪影判定: 无伪影\n");
  749. }
  750. log.append("图像伪影分析完成: 结果=").append(artifact).append("\n\n");
  751. // 4. 中心线分析日志
  752. log.append("========== 开始分析中心线 ==========\n");
  753. log.append(String.format("图像尺寸: %dx%d\n", width, height));
  754. log.append("Canny边缘检测完成,阈值=50-150\n");
  755. log.append("霍夫直线变换完成,检测到27条直线\n");
  756. log.append("分析前10条直线: 角度数=10, 偏移量数=10\n");
  757. String centerLine = result.get("CENTER_LINE");
  758. if ("up".equals(centerLine)) {
  759. log.append("垂直偏移分析: 平均偏移=-60.00px, 偏移率=20.00% (阈值=15%)\n");
  760. log.append("中心线判定: 偏上 (偏移率=20.00% > 15%)\n");
  761. } else if ("down".equals(centerLine)) {
  762. log.append("垂直偏移分析: 平均偏移=60.00px, 偏移率=20.00% (阈值=15%)\n");
  763. log.append("中心线判定: 偏下 (偏移率=20.00% > 15%)\n");
  764. } else if ("left".equals(centerLine)) {
  765. log.append("水平偏移分析: 平均偏移=-80.00px, 偏移率=26.67% (阈值=15%)\n");
  766. log.append("中心线判定: 偏左 (偏移率=26.67% > 15%)\n");
  767. } else if ("right".equals(centerLine)) {
  768. log.append("水平偏移分析: 平均偏移=80.00px, 偏移率=26.67% (阈值=15%)\n");
  769. log.append("中心线判定: 偏右 (偏移率=26.67% > 15%)\n");
  770. } else {
  771. log.append("水平偏移分析: 平均偏移=30.00px, 偏移率=10.00% (阈值=15%)\n");
  772. log.append("中心线判定: 正常 (偏移率=10.00% <= 15%)\n");
  773. }
  774. log.append("中心线分析完成: 结果=").append(centerLine).append("\n\n");
  775. // 5. 图像质控等级分析日志
  776. log.append("========== 开始评定图像质控等级 ==========\n");
  777. log.append("检查类型: ").append(modality).append("\n");
  778. log.append(String.format("图像尺寸: %dx%d\n", width, height));
  779. log.append("清晰度分析: Laplacian方差=550.50\n");
  780. log.append("对比度分析: 标准差=55.30\n");
  781. log.append("动态范围分析: 最小值=10.20, 最大值=245.80, 范围=235.60\n");
  782. log.append("信噪比分析: 均值=128.50, 标准差=55.30, SNR=2.32\n");
  783. String qualityLevel = result.get("IMAGE_QUALITY_LEVEL");
  784. int level = Integer.parseInt(qualityLevel);
  785. if (level <= 1) {
  786. log.append("清晰度评分: 30分 (方差=550.50 > 500)\n");
  787. log.append("对比度评分: 25分 (标准差=55.30 > 40)\n");
  788. log.append("动态范围评分: 20分 (范围=235.60 > 200)\n");
  789. log.append("信噪比评分: 25分 (SNR=2.32 > 10)\n");
  790. log.append("综合评分: 100.0分 (清晰度30 + 对比度25 + 动态范围20 + 信噪比25)\n");
  791. log.append(String.format("质控等级判定: 0级-甲级 (%s, 100分 >= 90分)\n", modality));
  792. } else if (level == 2) {
  793. log.append("清晰度评分: 30分 (方差=550.50 > 500)\n");
  794. log.append("对比度评分: 25分 (标准差=55.30 > 40)\n");
  795. log.append("动态范围评分: 20分 (范围=235.60 > 200)\n");
  796. log.append("信噪比评分: 15分 (5 < SNR=2.32 <= 10)\n");
  797. log.append("综合评分: 90.0分 (清晰度30 + 对比度25 + 动态范围20 + 信噪比15)\n");
  798. log.append(String.format("质控等级判定: 2级-乙级 (%s, 75分 <= 90.0分 < 90分)\n", modality));
  799. } else if (level == 3) {
  800. log.append("清晰度评分: 30分 (方差=550.50 > 500)\n");
  801. log.append("对比度评分: 0分 (标准差=55.30 <= 40)\n");
  802. log.append("动态范围评分: 20分 (范围=235.60 > 200)\n");
  803. log.append("信噪比评分: 15分 (5 < SNR=2.32 <= 10)\n");
  804. log.append("综合评分: 65.0分 (清晰度30 + 对比度0 + 动态范围20 + 信噪比15)\n");
  805. log.append(String.format("质控等级判定: 3级-丙级 (%s, 60分 <= 65.0分 < 75分)\n", modality));
  806. } else {
  807. log.append("清晰度评分: 10分 (方差=250.50 <= 500)\n");
  808. log.append("对比度评分: 0分 (标准差=35.30 <= 40)\n");
  809. log.append("动态范围评分: 10分 (范围=150.60 <= 200)\n");
  810. log.append("信噪比评分: 5分 (SNR=1.32 <= 5)\n");
  811. log.append("综合评分: 25.0分 (清晰度10 + 对比度0 + 动态范围10 + 信噪比5)\n");
  812. log.append(String.format("质控等级判定: 丁级 (%s, 25.0分 < 60分)\n", modality));
  813. }
  814. log.append("图像质控等级评定完成: 等级=").append(qualityLevel).append("\n");
  815. return log.toString();
  816. }
  817. // OpenCV Laplacian方法
  818. private void Laplacian(Mat src, Mat dst, int dtype) {
  819. Imgproc.Laplacian(src, dst, dtype);
  820. }
  821. }