From 52f49994aba201fedcef00efc4288ee0fed6bcb2 Mon Sep 17 00:00:00 2001 From: hang <872297557@qq.com> Date: Thu, 26 Mar 2026 15:21:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=B1=E5=83=8F=E9=98=85=E7=89=87=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=8D=A2=E5=88=B0=E5=90=8E=E7=AB=AF=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Helper/OtherTool/DicomSortHelper.cs | 193 ++++++++++++++++++ .../IRaCIS.Core.Application.xml | 21 ++ .../ImageAndDoc/DTO/DicomSeriesModel.cs | 4 + .../Service/Visit/SubjectVisitService.cs | 43 +++- 4 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs diff --git a/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs b/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs new file mode 100644 index 000000000..a3ce3b94d --- /dev/null +++ b/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs @@ -0,0 +1,193 @@ + +using FellowOakDicom.Imaging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; + +namespace IRaCIS.Core.Application.Helper; + + +public static class DicomSliceSorterFast +{ + public static List Sort( + IEnumerable source, + Func ippSelector, + Func iopSelector, + Func instanceSelector) + { + var items = source.ToList(); + if (items.Count < 2) + return items; + + var refIPP = Parse(ippSelector(items[0])); + var refIOP = Parse(iopSelector(items[0])); + + if (refIPP == null || refIOP == null || refIOP.Length != 6) + return items.OrderBy(instanceSelector).ToList(); + + // normal + var nx = refIOP[1] * refIOP[5] - refIOP[2] * refIOP[4]; + var ny = refIOP[2] * refIOP[3] - refIOP[0] * refIOP[5]; + var nz = refIOP[0] * refIOP[4] - refIOP[1] * refIOP[3]; + + var projections = new (T item, double dist)[items.Count]; + + double min = double.MaxValue; + double max = double.MinValue; + + // ---------- projection pass ---------- + for (int i = 0; i < items.Count; i++) + { + var ipp = Parse(ippSelector(items[i])); + if (ipp == null) + return items.OrderBy(instanceSelector).ToList(); + + var dx = refIPP[0] - ipp[0]; + var dy = refIPP[1] - ipp[1]; + var dz = refIPP[2] - ipp[2]; + + var dist = dx * nx + dy * ny + dz * nz; + + projections[i] = (items[i], dist); + + if (dist < min) min = dist; + if (dist > max) max = dist; + } + + // ---------- estimate spacing ---------- + var spacing = (max - min) / (items.Count - 1); + if (Math.Abs(spacing) < 1e-6) + return items.OrderBy(instanceSelector).ToList(); + + var result = new T[items.Count]; + + // ---------- O(n) placement ---------- + foreach (var p in projections) + { + var index = (int)Math.Round((p.dist - min) / spacing); + + index = Math.Clamp(index, 0, items.Count - 1); + + // collision fallback(极少发生) + while (result[index] != null!) + index = Math.Min(index + 1, items.Count - 1); + + result[index] = p.item; + } + + return result.ToList(); + } + + private static double[]? Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var s = value.Split('\\'); + var r = new double[s.Length]; + + for (int i = 0; i < s.Length; i++) + if (!double.TryParse(s[i], out r[i])) + return null; + + return r; + } +} + +public static class DicomSortHelper +{ + /// + /// DICOM Slice 排序(IPP + IOP) + /// 自动 fallback InstanceNumber + /// + public static List SortSlices( + IEnumerable source, + Func ippSelector, + Func iopSelector, + Func instanceNumberSelector) + { + var list = source.ToList(); + if (list.Count < 2) + return list; + + var first = list[0]; + + var reference = ParseVector(ippSelector(first)); + var iop = ParseVector(iopSelector(first)); + + // ===== fallback 条件 ===== + if (reference == null || iop == null || iop.Length != 6) + return list.OrderBy(instanceNumberSelector).ToList(); + + // row / column direction + var row = new[] { iop[0], iop[1], iop[2] }; + var col = new[] { iop[3], iop[4], iop[5] }; + + // normal = row × col + var normal = new[] + { + row[1]*col[2] - row[2]*col[1], + row[2]*col[0] - row[0]*col[2], + row[0]*col[1] - row[1]*col[0] + }; + + // 如果法向量异常 → fallback + if (IsZeroVector(normal)) + return list.OrderBy(instanceNumberSelector).ToList(); + + // ===== 计算距离 ===== + var sorted = list + .Select(item => + { + var ipp = ParseVector(ippSelector(item)); + + if (ipp == null) + return (item, distance: double.MinValue); + + var vec0 = reference[0] - ipp[0]; + var vec1 = reference[1] - ipp[1]; + var vec2 = reference[2] - ipp[2]; + + var distance = + vec0 * normal[0] + + vec1 * normal[1] + + vec2 * normal[2]; + + return (item, distance); + }) + .OrderByDescending(x => x.distance) + .Select(x => x.item) + .ToList(); + + return sorted; + } + + // ---------------- helpers ---------------- + + private static double[]? ParseVector(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var parts = value.Split('\\'); + var result = new double[parts.Length]; + + for (int i = 0; i < parts.Length; i++) + { + if (!double.TryParse(parts[i], out result[i])) + return null; + } + + return result; + } + + private static bool IsZeroVector(double[] v) + { + const double eps = 1e-6; + return Math.Abs(v[0]) < eps && + Math.Abs(v[1]) < eps && + Math.Abs(v[2]) < eps; + } +} + + diff --git a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml index fc1d9b733..382282981 100644 --- a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml +++ b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml @@ -12091,6 +12091,11 @@ 影像标记 + + + 影像标记类型 + + 影像工具 @@ -16054,6 +16059,12 @@ + + + DICOM Slice 排序(IPP + IOP) + 自动 fallback InstanceNumber + + github 链接:https://github.com/lanceliao/china-holiday-calender?tab=readme-ov-file  @@ -17827,6 +17838,16 @@ 任务类型 + + + 分割分组名称 + + + + + SegmentName + + 分割的Json diff --git a/IRaCIS.Core.Application/Service/ImageAndDoc/DTO/DicomSeriesModel.cs b/IRaCIS.Core.Application/Service/ImageAndDoc/DTO/DicomSeriesModel.cs index 5ec4c58db..28bc5f748 100644 --- a/IRaCIS.Core.Application/Service/ImageAndDoc/DTO/DicomSeriesModel.cs +++ b/IRaCIS.Core.Application/Service/ImageAndDoc/DTO/DicomSeriesModel.cs @@ -108,6 +108,10 @@ namespace IRaCIS.Core.Application.Contracts.Dicom.DTO public string WindowCenter { get; set; } [JsonIgnore] public string WindowWidth { get; set; } + [JsonIgnore] + public string ImagePositionPatient { get; set; } + [JsonIgnore] + public string ImageOrientationPatient { get; set; } public DateTime? RowDate { get; set; } } diff --git a/IRaCIS.Core.Application/Service/Visit/SubjectVisitService.cs b/IRaCIS.Core.Application/Service/Visit/SubjectVisitService.cs index bdddf04e0..9bf4dbc8a 100644 --- a/IRaCIS.Core.Application/Service/Visit/SubjectVisitService.cs +++ b/IRaCIS.Core.Application/Service/Visit/SubjectVisitService.cs @@ -2,6 +2,7 @@ using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts.Dicom.DTO; using IRaCIS.Core.Application.Filter; +using IRaCIS.Core.Application.Helper; using IRaCIS.Core.Application.Interfaces; using IRaCIS.Core.Application.Service.Reading.Dto; using IRaCIS.Core.Domain.Models; @@ -254,7 +255,7 @@ namespace IRaCIS.Core.Application.Services .Where(t => isImageFilter ? ("|" + criterionModalitys + "|").Contains("|" + t.ModalityForEdit + "|") : true) .WhereIf(isReading == 1 || isQCFinished, s => s.IsDeleted == false) //预览靶段标注上传的影像 影像后处理 上传了新的影像 还要原始dsa - .WhereIf(isImageSegmentLabel == true && isVisitTask && (criterionType == CriterionType.OCT || criterionType == CriterionType.IVUS), t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "OCT") + .WhereIf(isImageSegmentLabel == true && isVisitTask && (criterionType == CriterionType.OCT || criterionType == CriterionType.IVUS), t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "OCT") .Select(k => new VisitStudyDTO() { InstanceCount = k.InstanceCount, @@ -362,7 +363,7 @@ namespace IRaCIS.Core.Application.Services } else { - series = await _taskSeriesRepository .Where(s => s.Id == inDto.SeriesId).ProjectTo(_mapper.ConfigurationProvider).FirstNotNullAsync(); + series = await _taskSeriesRepository.Where(s => s.Id == inDto.SeriesId).ProjectTo(_mapper.ConfigurationProvider).FirstNotNullAsync(); var instanceList = await _taskInstanceRepository.Where(t => t.SeriesId == inDto.SeriesId) .Select(t => new { t.SeriesId, t.StudyId, t.Id, t.InstanceNumber, t.Path, t.NumberOfFrames, t.WindowCenter, t.WindowWidth, t.HtmlPath, t.SliceLocation, t.FileSize }).ToListAsync(); @@ -556,13 +557,13 @@ namespace IRaCIS.Core.Application.Services .WhereIf(isManualGenerate, t => t.SubjectCriteriaEvaluationVisitStudyFilterList.Any(t => t.TrialReadingCriterionId == taskInfo.TrialReadingCriterionId && t.IsConfirmed && t.IsReading)) //影像后处理 上传了新的影像 还要原始dsa .WhereIf(taskInfo.IsHaveTaskStudy && taskInfo.CriterionType == CriterionType.OCT, - t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" ) + t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA") .WhereIf(taskInfo.CriterionType == CriterionType.IVUS, - t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "IVUS") + t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "IVUS") .WhereIf(taskInfo.IsHaveTaskStudy == false && taskInfo.CriterionType == CriterionType.OCT, - t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "OCT") + t => t.ModalityForEdit == "XA" || t.ModalityForEdit == "DSA" || t.ModalityForEdit == "OCT") //其他 不应该看原始影像 @@ -588,7 +589,7 @@ namespace IRaCIS.Core.Application.Services if (studyIds.Count > 0) { var instanceList = await _dicomInstanceRepository.Where(t => studyIds.Contains(t.StudyId) && t.IsReading) - .Select(t => new { t.SeriesId, t.Id, t.InstanceNumber, t.Path, t.NumberOfFrames, t.WindowCenter, t.WindowWidth, t.HtmlPath, t.IsReading, t.FileSize }).ToListAsync(); + .Select(t => new { t.SeriesId, t.Id, t.InstanceNumber, t.Path, t.NumberOfFrames, t.WindowCenter, t.WindowWidth, t.HtmlPath, t.IsReading, t.FileSize, t.ImagePositionPatient, t.ImageOrientationPatient }).ToListAsync(); List seriesLists = await _dicomSeriesRepository.Where(s => studyIds.Contains(s.StudyId)) .WhereIf(isManualGenerate == false, t => t.IsReading) @@ -597,11 +598,23 @@ namespace IRaCIS.Core.Application.Services foreach (var t in dicomStudyList) { + t.SeriesList = seriesLists.Where(s => s.StudyId == t.StudyId).OrderBy(s => s.SeriesNumber).ThenBy(s => s.SeriesTime).ToList(); t.SeriesList.ForEach(series => { - series.InstanceInfoList = instanceList.Where(t => t.SeriesId == series.Id).OrderBy(t => t.InstanceNumber).Select(k => + + var instances = instanceList.Where(x => x.SeriesId == series.Id); + + // ⭐ DICOM 空间排序(带兜底) + var sorted = DicomSortHelper.SortSlices( + instances, + x => x.ImagePositionPatient, + x => x.ImageOrientationPatient, + x => x.InstanceNumber + ); + + series.InstanceInfoList = sorted.Select(k => new InstanceBasicInfo() { Id = k.Id, @@ -642,6 +655,20 @@ namespace IRaCIS.Core.Application.Services { study.SeriesList = study.SeriesList.OrderBy(s => s.SeriesNumber).ThenBy(s => s.SeriesTime).ToList(); + study.SeriesList.ForEach(series => + { + + // ⭐ DICOM 空间排序(带兜底) + var sorted = DicomSortHelper.SortSlices( + series.InstanceInfoList, + x => x.ImagePositionPatient, + x => x.ImageOrientationPatient, + x => x.InstanceNumber + ); + + series.InstanceInfoList = sorted; + }); + study.InstanceCount = study.SeriesList.SelectMany(t => t.InstanceInfoList).Count(); } @@ -823,7 +850,7 @@ namespace IRaCIS.Core.Application.Services var instanceCount = await _noneDicomStudyFileRepository.Where(t => t.IsReading) .WhereIf(taskInfo.IsHaveTaskNoneDicomStudyFile == false && taskInfo.IsMarkNoneDicomStudy == true, x => x.ImageLabelNoneDicomStudyId == item.StudyId) - .WhereIf(taskInfo.IsHaveTaskNoneDicomStudyFile && taskInfo.IsMarkNoneDicomStudy, x => x.OriginNoneDicomStudyId == item.StudyId && x.VisitTaskId==indto.VisitTaskId) + .WhereIf(taskInfo.IsHaveTaskNoneDicomStudyFile && taskInfo.IsMarkNoneDicomStudy, x => x.OriginNoneDicomStudyId == item.StudyId && x.VisitTaskId == indto.VisitTaskId) .WhereIf(taskInfo.IsHaveTaskNoneDicomStudyFile == false && taskInfo.IsMarkNoneDicomStudy == false, x => x.NoneDicomStudyId == item.StudyId) .Where(t => !t.FileType.Contains(StaticData.FileType.Zip)) .CountAsync();