实际上传:DX腰椎正侧位、DX骨盆正位、DX膝关节正侧位 解析结果:DX 膝关节侧位片、DX 腰椎正位片、DX 腰椎侧位片 缺失项目:DX 骨盆正位
核心问题:
| 标签 | 标签名 | 说明 | 示例值 |
|---|---|---|---|
| (0008,0060) | Modality | 检查类型 | DX, CT, MR |
| (0008,1030) | StudyDescription | Study级描述 | DX腰椎正侧位+DX骨盆正位 |
| (0008,103E) | SeriesDescription | Series级描述 | 腰椎正位 |
| (0018,0015) | BodyPartExamined | 检查部位 | SPINE, PELVIS, KNEE |
| (0018,1030) | ProtocolName | 协议名称 | LUMBAR SPINE AP |
| (0018,5100) | ViewPosition | 体位 | AP, PA, LAT |
核心文件:MultiSeriesExamAggregator.java
关键改进:
+、、 等分隔符使用方法:
@Autowired
private MultiSeriesExamAggregator examAggregator;
// 在上传服务中
List<AggregatedExamItem> examItems = examAggregator.aggregateExamItems(studyDirPath);
修改文件:ExamItemEnum.java
/**
* DX - 腰椎正侧位
*/
DX_LUMBAR_SPINE_AP_LAT("DX", "腰椎", "正侧位", "DX 腰椎正侧位",
Arrays.asList("LUMBAR", "LUMBAR SPINE", "腰椎", "腰"),
Arrays.asList("AP+LAT", "AP/LAT", "AP LAT", "正侧位", "正位+侧位")),
/**
* DX - 膝关节正侧位
*/
DX_KNEE_AP_LAT("DX", "膝关节", "正侧位", "DX 膝关节正侧位",
Arrays.asList("KNEE", "膝关节", "膝盖"),
Arrays.asList("AP+LAT", "AP/LAT", "AP LAT", "正侧位", "正位+侧位")),
在 match() 方法中:
// 优先级1:完整匹配(modality + bodyPart + scanType)
if (bodyPartMatch && scanTypeMatch) {
score += 20; // 完整匹配最高优先级
}
// 优先级2:复合体位匹配
if (item.getScanTypeAliases().contains("正侧位") &&
normalizedBodyPart.contains("正") && normalizedBodyPart.contains("侧")) {
score += 15;
}
// 优先级3:部位包含匹配
if (normalizedBodyPart.contains(normalizedAlias)) {
score += 10;
}
修改文件:DicomMetadataParser.java
/**
* 提取体位信息(新增方法)
*/
private PositionInfo extractPositionInfo(DicomParser.DicomData dicomData) {
PositionInfo positionInfo = new PositionInfo();
// 1. 优先使用 ViewPosition 标签 (0018,5100)
String viewPosition = dicomData.getViewPosition();
if (StringUtils.hasText(viewPosition)) {
positionInfo.setOriginalPosition(viewPosition);
positionInfo.setNormalizedPosition(normalizeViewPosition(viewPosition));
}
// 2. 从 ProtocolName 提取 (0018,1030)
String protocolName = dicomData.getProtocolName();
if (StringUtils.hasText(protocolName)) {
positionInfo.setProtocolPosition(extractPositionFromProtocol(protocolName));
}
// 3. 从 SeriesDescription 提取
String seriesDesc = dicomData.getSeriesDescription();
if (StringUtils.hasText(seriesDesc)) {
positionInfo.setDescriptionPosition(extractPositionFromDescription(seriesDesc));
}
return positionInfo;
}
/**
* 标准化 ViewPosition
*/
private String normalizeViewPosition(String viewPosition) {
String pos = viewPosition.toUpperCase().trim();
switch (pos) {
case "AP":
case "ANTEROPOSTERIOR":
return "正位";
case "PA":
case "POSTEROANTERIOR":
return "正位";
case "LAT":
case "LATERAL":
return "侧位";
case "OBL":
case "OBLIQUE":
return "斜位";
default:
return viewPosition;
}
}
// ProtocolName 通常包含:部位 + 体位
// 例如:"LUMBAR SPINE AP" -> 腰椎正位
if (StringUtils.hasText(protocolName)) {
String[] parts = protocolName.split("\\s+");
for (String part : parts) {
if (isBodyPartKeyword(part)) {
bodyPart = part;
} else if (isPositionKeyword(part)) {
scanType = part;
}
}
}
新增解析规则:
/**
* 智能解析 StudyDescription
* 识别多种复合检查格式
*/
private StudyExamInfo parseStudyDescription(String studyDesc) {
StudyExamInfo info = new StudyExamInfo();
// 格式1:用 "+" 分隔
// "DX腰椎正侧位+DX骨盆正位+DX膝关节正侧位"
if (studyDesc.contains("+")) {
return parsePlusDelimited(studyDesc);
}
// 格式2:用 "、" 分隔
// "DX腰椎正侧位、DX骨盆正位、DX膝关节正侧位"
if (studyDesc.contains("、")) {
return parseCommaDelimited(studyDesc);
}
// 格式3:关键词识别
// "腰椎正侧位"
return parseKeywordBased(studyDesc);
}
思路:使用 NLP 技术智能识别检查项目
// 基于规则 + 机器学习的混合方法
public class MLExamParser {
// 训练数据集
private List<TrainingExample> trainingData = Arrays.asList(
new TrainingExample("腰椎正侧位", "DX 腰椎正侧位"),
new TrainingExample("腰椎AP+LAT", "DX 腰椎正侧位"),
// ... 更多训练数据
);
public ExamPrediction predict(String description) {
// 1. 基于规则的匹配
ExamItemEnum ruleBased = matchByRule(description);
if (ruleBased != null && ruleBased.getConfidence() > 0.9) {
return new ExamPrediction(ruleBased, 0.95);
}
// 2. 基于相似度的匹配
ExamItemEnum similarityBased = matchBySimilarity(description);
return new ExamPrediction(similarityBased, 0.85);
}
}
文件:找到上传服务类(如 DicomUploadService.java)
@Autowired
private DicomMetadataParser metadataParser;
@Autowired
private MultiSeriesExamAggregator examAggregator;
public void processUploadedDicom(String studyDirPath) {
// 新方式:使用聚合器
List<AggregatedExamItem> examItems = examAggregator.aggregateExamItems(studyDirPath);
for (AggregatedExamItem item : examItems) {
// 保存到数据库
StudyInfo studyInfo = new StudyInfo();
studyInfo.setExamItemName(item.getExamItemName());
studyInfo.setModality(item.getModality());
// ... 设置其他字段
studyInfoMapper.insert(studyInfo);
log.info("保存检查项目: {}, 类型: {}",
item.getExamItemName(), item.getExamType());
}
}
/**
* 对于同一 Study 的多个 Series,合并检查项目名称
*/
private String mergeExamItemsForStudy(List<SeriesMetadata> seriesList) {
// 1. 按部位分组
Map<String, List<SeriesMetadata>> groupedByBodyPart =
seriesList.stream().collect(Collectors.groupingBy(
s -> extractBodyPart(s)
));
// 2. 为每个部位生成检查项目名称
List<String> examItems = new ArrayList<>();
for (Map.Entry<String, List<SeriesMetadata>> entry : groupedByBodyPart.entrySet()) {
String bodyPart = entry.getKey();
List<SeriesMetadata> series = entry.getValue();
Set<String> positions = extractAllPositions(series);
String examItem = generateExamItem(bodyPart, positions);
examItems.add(examItem);
}
// 3. 用 "+" 连接多个部位
return String.join(" + ", examItems);
}
| 输入 | 预期输出 | 说明 |
|---|---|---|
| DX腰椎正侧位+DX骨盆正位 | DX 腰椎正侧位 + DX 骨盆正位 | 多部位识别 |
| 腰椎AP+腰椎LAT | DX 腰椎正侧位 | 正侧位合并 |
| DX膝关节正侧位 | DX 膝关节正侧位 | 单部位正侧位 |
| DX骨盆AP | DX 骨盆正位片 | 单体位 |
| 颈椎正侧位、腰椎正侧位 | DX 颈椎正侧位 + DX 腰椎正侧位 | 中文分隔符 |
上传测试数据
查看解析日志
tail -f logs/application.log | grep "聚合解析"
sql
SELECT study_id, exam_item_name, modality, body_part
FROM study_info
ORDER BY create_time DESC
LIMIT 10;
@Component
public class ExamMatchCache {
private final LoadingCache<String, ExamItemEnum> matchCache =
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(key -> {
String[] parts = key.split("\\|");
return ExamItemEnum.match(parts[0], parts[1], parts[2]);
});
public ExamItemEnum match(String modality, String bodyPart, String scanType) {
String key = modality + "|" + bodyPart + "|" + scanType;
return matchCache.get(key);
}
}
// 对于大量 DICOM 文件,使用并行流处理
List<SeriesMetadata> seriesList = dicomFiles.parallelStream()
.map(this::parseSeriesMetadata)
.filter(Objects::nonNull)
.collect(Collectors.toList());
@Aspect
@Component
public class ExamParserMonitor {
@Around("execution(* *..DicomMetadataParser.parseExamInfo(..))")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed();
long duration = System.currentTimeMillis() - start;
if (result == null) {
// 记录失败的解析请求
logFailure(pjp.getArgs());
}
return result;
}
}
/**
* 每天生成解析失败报告
*/
@Scheduled(cron = "0 0 2 * * ?")
public void generateFailureReport() {
List<ParseFailure> failures = getRecentFailures(24); // 最近24小时
// 统计失败类型
Map<String, Long> failureTypes = failures.stream()
.collect(Collectors.groupingBy(
f -> classifyFailure(f),
Collectors.counting()
));
log.info("解析失败报告: {}", failureTypes);
// 输出未识别的检查项目
failures.stream()
.filter(f -> "UNKNOWN_BODY_PART".equals(classifyFailure(f)))
.forEach(f -> log.info("未识别部位: {}", f.getStudyDescription()));
}
MultiSeriesExamAggregator(已完成)排查步骤:
检查 DICOM 原始标签值
log.info("DICOM原始数据: modality={}, bodyPart={}, studyDesc={}",
dicomData.getModality(),
dicomData.getBodyPartExamined(),
dicomData.getStudyDescription());
grep "开始匹配|部位匹配|扫描方式匹配" logs/application.log
3. 添加缺失的枚举或别名
### 问题2:性能下降
**解决方案**:
1. 启用缓存(见5.1)
2. 使用并行处理(见5.2)
3. 增加数据库索引
### 问题3:骨盆检查缺失
**可能原因**:
- DICOM 文件中缺少 BodyPartExamined 标签
- StudyDescription 中未明确提及"骨盆"
**解决方案**:
```java
// 增强 ProtocolName 解析
if ("PELVIS".equals(protocolName)) {
bodyPart = "骨盆";
}
本优化方案通过多层级聚合策略,解决了当前系统的主要问题:
预期提升: