From cca587fb17fba72256619aaeb306f24f90f16eb8 Mon Sep 17 00:00:00 2001 From: hang <872297557@qq.com> Date: Mon, 13 Apr 2026 15:38:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=BC=82=E5=B8=B8=E5=8F=96?= =?UTF-8?q?=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HostConfig/SyncFileRecoveryService.cs | 2 +- .../HostService/SyncFileRecoveryService.cs | 4 +- .../Helper/OtherTool/DicomSortHelper.cs | 534 ++++++++++++++++++ .../IRaCIS.Core.Application.xml | 71 ++- IRaCIS.Core.Application/TestService.cs | 39 +- 5 files changed, 645 insertions(+), 5 deletions(-) diff --git a/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs index d8e717be7..1a994d722 100644 --- a/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs +++ b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs @@ -150,7 +150,7 @@ public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger 300 ? ex.Message.Substring(0, 300) : ex.Message; } log.EndTime = DateTime.Now; diff --git a/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs b/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs index e79796fec..33e6521bd 100644 --- a/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs +++ b/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs @@ -34,7 +34,7 @@ public class SyncFileRecoveryService(IServiceScopeFactory _scopeFactory, FileSyn return; } - using var scope = _scopeFactory.CreateScope(); + using var scope = _scopeFactory.CreateScope(); var fileUploadRecordRepository = scope.ServiceProvider.GetRequiredService>(); // 延迟启动,保证主机快速启动 @@ -143,7 +143,7 @@ public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger 300 ? ex.Message.Substring(0, 300) : ex.Message; } log.EndTime = DateTime.Now; diff --git a/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs b/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs index a3ce3b94d..ee63f5c53 100644 --- a/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs +++ b/IRaCIS.Core.Application/Helper/OtherTool/DicomSortHelper.cs @@ -1,11 +1,15 @@  +using FellowOakDicom; using FellowOakDicom.Imaging; +using FellowOakDicom.Imaging.Codec; +using FellowOakDicom.IO.Buffer; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Processing; namespace IRaCIS.Core.Application.Helper; +#region 废弃 public static class DicomSliceSorterFast { @@ -94,6 +98,536 @@ public static class DicomSliceSorterFast } } +#endregion + +public sealed class MaskRegion +{ + public int X { get; } + public int Y { get; } + public int Width { get; } + public int Height { get; } + public MaskRegion(int x, int y, int width, int height) + { + if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width)); + if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height)); + X = x; + Y = y; + Width = width; + Height = height; + } +} + + +public sealed class DicomMaskOptions +{ + /// + /// 要处理的帧,null 或空表示全部帧。 + /// + public IReadOnlyCollection? FrameIndices { get; init; } + + /// + /// 灰度图像的遮挡像素值,默认 0。 + /// 注意:这表示写入的原始像素值,不保证视觉上一定为黑色。 + /// + public ushort GrayscaleMaskValue { get; init; } = 0; + + /// + /// 彩色图像的遮挡值,默认 [0,0,0]。 + /// 对 RGB 表示黑色;对 YBR 则表示三个分量都写 0。 + /// + public byte[] ColorMaskValue { get; init; } = new byte[] { 0, 0, 0 }; + + /// + /// 严格保持原始 TransferSyntax。 + /// 如果无法重新编码到原语法,则抛异常。 + /// + public bool StrictKeepTransferSyntax { get; init; } = true; + + /// + /// 是否更新 BurnedInAnnotation 为 NO。 + /// + public bool UpdateBurnedInAnnotationToNo { get; init; } = false; +} + + + +public static class DicomPixelMasker +{ + public static async Task MaskAsync( + Stream input, + IEnumerable regions, + DicomMaskOptions? options = null, + CancellationToken cancellationToken = default) + { + var output = new MemoryStream(); + await MaskAsync(input, output, regions, options, cancellationToken).ConfigureAwait(false); + output.Position = 0; + return output; + } + + public static async Task MaskAsync( + Stream input, + Stream output, + IEnumerable regions, + DicomMaskOptions? options = null, + CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (output == null) throw new ArgumentNullException(nameof(output)); + + var regionList = regions?.ToList() ?? throw new ArgumentNullException(nameof(regions)); + if (regionList.Count == 0) + throw new ArgumentException("At least one mask region is required.", nameof(regions)); + options ??= new DicomMaskOptions(); + + if (input.CanSeek) + input.Position = 0; + + var originalFile = await DicomFile.OpenAsync(input, FileReadOption.ReadAll).ConfigureAwait(false); + var originalDataset = originalFile.Dataset; + + ValidateDataset(originalDataset); + + var originalTransferSyntax = originalFile.FileMetaInfo.TransferSyntax; + var originalPhotometric = originalDataset.GetSingleValueOrDefault(DicomTag.PhotometricInterpretation, string.Empty); + + var rows = originalDataset.GetSingleValue(DicomTag.Rows); + var cols = originalDataset.GetSingleValue(DicomTag.Columns); + var bitsAllocated = originalDataset.GetSingleValue(DicomTag.BitsAllocated); + var bitsStored = originalDataset.GetSingleValueOrDefault(DicomTag.BitsStored, bitsAllocated); + var samplesPerPixel = originalDataset.GetSingleValue(DicomTag.SamplesPerPixel); + var pixelRepresentation = originalDataset.GetSingleValueOrDefault(DicomTag.PixelRepresentation, (ushort)0); + var planarConfiguration = originalDataset.GetSingleValueOrDefault(DicomTag.PlanarConfiguration, (ushort)0); + //log?.Invoke($"Rows={rows}, Cols={cols}, BitsAllocated={bitsAllocated}, BitsStored={bitsStored}, SamplesPerPixel={samplesPerPixel}, PixelRepresentation={pixelRepresentation}, PlanarConfiguration={planarConfiguration}"); + + + // 1. 转为工作用的未压缩 DICOM + var workingFile = await EnsureUncompressedAsync(originalFile, cancellationToken).ConfigureAwait(false); + var workingDataset = workingFile.Dataset; + + // 2. 修改像素 + MaskPixelDataInPlace(workingDataset, regionList, options); + + // 3. 保持原 PhotometricInterpretation + if (!string.IsNullOrWhiteSpace(originalPhotometric)) + { + workingDataset.AddOrUpdate(DicomTag.PhotometricInterpretation, originalPhotometric); + } + + // 4. 可选更新 BurnedInAnnotation + if (options.UpdateBurnedInAnnotationToNo) + { + workingDataset.AddOrUpdate(DicomTag.BurnedInAnnotation, "NO"); + } + + // 5. 转回原始 TransferSyntax + var finalFile = await ReEncodeToOriginalTransferSyntaxAsync( + workingFile, + originalTransferSyntax, + options.StrictKeepTransferSyntax, + cancellationToken).ConfigureAwait(false); + + finalFile.FileMetaInfo.TransferSyntax = originalTransferSyntax; + + if (output.CanSeek) + output.SetLength(0); + + await finalFile.SaveAsync(output).ConfigureAwait(false); + + if (output.CanSeek) + output.Position = 0; + } + + /// + /// 验证dicom tag + /// + /// + /// + private static void ValidateDataset(DicomDataset dataset) + { + if (!dataset.Contains(DicomTag.PixelData)) + throw new NotSupportedException("DICOM dataset does not contain Pixel Data."); + + _ = dataset.GetSingleValue(DicomTag.Rows); + _ = dataset.GetSingleValue(DicomTag.Columns); + _ = dataset.GetSingleValue(DicomTag.BitsAllocated); + _ = dataset.GetSingleValue(DicomTag.SamplesPerPixel); + } + + /// + /// 转为工作用的未压缩 DICOM + /// + /// + /// + /// + /// + private static async Task EnsureUncompressedAsync(DicomFile sourceFile, CancellationToken cancellationToken) + { + var ts = sourceFile.FileMetaInfo.TransferSyntax; + + if (!ts.IsEncapsulated) + { + return new DicomFile(sourceFile.Dataset.Clone()); + } + + try + { + var transcoder = new DicomTranscoder(ts, DicomTransferSyntax.ExplicitVRLittleEndian); + var transcoded = await Task.Run(() => transcoder.Transcode(sourceFile), cancellationToken).ConfigureAwait(false); + return transcoded; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to decode compressed DICOM from TransferSyntax {ts.UID.UID}. " + + "Please ensure fo-dicom codecs are available in the current Linux runtime.", + ex); + } + } + + /// + /// 转回原始 TransferSyntax + /// + /// + /// + /// + /// + /// + /// + private static async Task ReEncodeToOriginalTransferSyntaxAsync( + DicomFile uncompressedFile, + DicomTransferSyntax originalTransferSyntax, + bool strictKeepTransferSyntax, + CancellationToken cancellationToken) + { + if (!originalTransferSyntax.IsEncapsulated) + { + if (uncompressedFile.FileMetaInfo.TransferSyntax == originalTransferSyntax) + { + return uncompressedFile; + } + + try + { + var transcoder = new DicomTranscoder( + uncompressedFile.FileMetaInfo.TransferSyntax, + originalTransferSyntax); + + return await Task.Run(() => transcoder.Transcode(uncompressedFile), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to convert dataset back to original uncompressed TransferSyntax {originalTransferSyntax.UID.UID}.", + ex); + } + } + + try + { + var currentTs = uncompressedFile.FileMetaInfo.TransferSyntax; + var transcoder = new DicomTranscoder(currentTs, originalTransferSyntax); + return await Task.Run(() => transcoder.Transcode(uncompressedFile), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + if (strictKeepTransferSyntax) + { + throw new InvalidOperationException( + $"Failed to re-encode DICOM back to original compressed TransferSyntax {originalTransferSyntax.UID.UID}.", + ex); + } + + return uncompressedFile; + } + } + + /// + /// 修改像素 + /// + /// + /// + /// + private static void MaskPixelDataInPlace( + DicomDataset dataset, + IReadOnlyList regions, + DicomMaskOptions options) + { + var rows = dataset.GetSingleValue(DicomTag.Rows); + var cols = dataset.GetSingleValue(DicomTag.Columns); + var bitsAllocated = dataset.GetSingleValue(DicomTag.BitsAllocated); + var samplesPerPixel = dataset.GetSingleValue(DicomTag.SamplesPerPixel); + var pixelRepresentation = dataset.GetSingleValueOrDefault(DicomTag.PixelRepresentation, (ushort)0); + var photometric = dataset.GetSingleValueOrDefault(DicomTag.PhotometricInterpretation, string.Empty); + var planarConfiguration = dataset.GetSingleValueOrDefault(DicomTag.PlanarConfiguration, (ushort)0); + + var pixelData = DicomPixelData.Create(dataset, false); + var frameCount = pixelData.NumberOfFrames; + + var framesToProcess = ResolveFrames(frameCount, options.FrameIndices); + + var newFrames = new List(frameCount); + + for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) + { + var frame = pixelData.GetFrame(frameIndex); + var data = frame.Data.ToArray(); + + if (framesToProcess.Contains(frameIndex)) + { + ApplyMaskToFrame( + data, + rows, + cols, + bitsAllocated, + samplesPerPixel, + pixelRepresentation, + photometric, + planarConfiguration, + regions, + options); + } + + newFrames.Add(new MemoryByteBuffer(data)); + } + + ReplacePixelDataFrames(dataset, pixelData, newFrames); + } + + private static HashSet ResolveFrames(int frameCount, IReadOnlyCollection? frameIndices) + { + if (frameIndices == null || frameIndices.Count == 0) + { + return Enumerable.Range(0, frameCount).ToHashSet(); + } + + var result = new HashSet(); + foreach (var i in frameIndices) + { + if (i < 0 || i >= frameCount) + throw new ArgumentOutOfRangeException(nameof(frameIndices), $"Frame index {i} out of range [0, {frameCount - 1}]"); + + result.Add(i); + } + + return result; + } + + private static void ReplacePixelDataFrames( + DicomDataset dataset, + DicomPixelData sourcePixelData, + IReadOnlyList frames) + { + dataset.Remove(DicomTag.PixelData); + + var newPixelData = DicomPixelData.Create(dataset, true); + //newPixelData.BitsAllocated = sourcePixelData.BitsAllocated; + newPixelData.BitsStored = sourcePixelData.BitsStored; + newPixelData.HighBit = sourcePixelData.HighBit; + newPixelData.SamplesPerPixel = sourcePixelData.SamplesPerPixel; + newPixelData.PixelRepresentation = sourcePixelData.PixelRepresentation; + newPixelData.PlanarConfiguration = sourcePixelData.PlanarConfiguration; + newPixelData.Height = sourcePixelData.Height; + newPixelData.Width = sourcePixelData.Width; + newPixelData.PhotometricInterpretation = sourcePixelData.PhotometricInterpretation; + + foreach (var frame in frames) + { + newPixelData.AddFrame(frame); + } + } + + private static void ApplyMaskToFrame( + byte[] frameData, + int rows, + int cols, + int bitsAllocated, + int samplesPerPixel, + ushort pixelRepresentation, + string photometric, + ushort planarConfiguration, + IReadOnlyList regions, + DicomMaskOptions options) + { + if (samplesPerPixel == 1) + { + if (bitsAllocated == 8) + { + //8 位灰度 + ApplyMask_Grayscale8(frameData, rows, cols, regions, (byte)Math.Min(options.GrayscaleMaskValue, byte.MaxValue)); + return; + } + + // 16 位灰度 + if (bitsAllocated == 16) + { + ApplyMask_Grayscale16(frameData, rows, cols, regions, options.GrayscaleMaskValue); + return; + } + + throw new NotSupportedException($"Unsupported grayscale BitsAllocated={bitsAllocated}, Photometric={photometric}."); + } + + //RGB 彩图 + if (samplesPerPixel == 3) + { + if (bitsAllocated != 8) + { + throw new NotSupportedException($"Unsupported color image: SamplesPerPixel=3 but BitsAllocated={bitsAllocated}."); + } + + if (planarConfiguration == 0) + { + ApplyMask_Color8_Interleaved(frameData, rows, cols, regions, options.ColorMaskValue); + return; + } + + if (planarConfiguration == 1) + { + ApplyMask_Color8_Planar(frameData, rows, cols, regions, options.ColorMaskValue); + return; + } + + throw new NotSupportedException($"Unsupported PlanarConfiguration={planarConfiguration}, Photometric={photometric}."); + } + + throw new NotSupportedException( + $"Unsupported image format: SamplesPerPixel={samplesPerPixel}, BitsAllocated={bitsAllocated}, PixelRepresentation={pixelRepresentation}, Photometric={photometric}."); + } + + private static void ApplyMask_Grayscale8( + byte[] data, + int rows, + int cols, + IReadOnlyList regions, + byte maskValue) + { + foreach (var region in regions) + { + var (left, top, right, bottom) = ClipRegion(region, cols, rows); + if (left >= right || top >= bottom) continue; + + for (int y = top; y < bottom; y++) + { + int rowOffset = y * cols; + for (int x = left; x < right; x++) + { + data[rowOffset + x] = maskValue; + } + } + } + } + + private static void ApplyMask_Grayscale16( + byte[] data, + int rows, + int cols, + IReadOnlyList regions, + ushort maskValue) + { + foreach (var region in regions) + { + var (left, top, right, bottom) = ClipRegion(region, cols, rows); + if (left >= right || top >= bottom) continue; + + for (int y = top; y < bottom; y++) + { + int rowOffset = y * cols; + for (int x = left; x < right; x++) + { + int pixelIndex = rowOffset + x; + int byteIndex = pixelIndex * 2; + + data[byteIndex] = (byte)(maskValue & 0xFF); + data[byteIndex + 1] = (byte)((maskValue >> 8) & 0xFF); + } + } + } + } + + private static void ApplyMask_Color8_Interleaved( + byte[] data, + int rows, + int cols, + IReadOnlyList regions, + byte[] color) + { + if (color == null || color.Length < 3) + throw new ArgumentException("ColorMaskValue must contain at least 3 bytes."); + + byte c0 = color[0]; + byte c1 = color[1]; + byte c2 = color[2]; + + foreach (var region in regions) + { + var (left, top, right, bottom) = ClipRegion(region, cols, rows); + if (left >= right || top >= bottom) continue; + + for (int y = top; y < bottom; y++) + { + for (int x = left; x < right; x++) + { + int pixelIndex = y * cols + x; + int byteIndex = pixelIndex * 3; + + data[byteIndex] = c0; + data[byteIndex + 1] = c1; + data[byteIndex + 2] = c2; + } + } + } + } + + private static void ApplyMask_Color8_Planar( + byte[] data, + int rows, + int cols, + IReadOnlyList regions, + byte[] color) + { + if (color == null || color.Length < 3) + throw new ArgumentException("ColorMaskValue must contain at least 3 bytes."); + + int planeSize = rows * cols; + + byte c0 = color[0]; + byte c1 = color[1]; + byte c2 = color[2]; + + foreach (var region in regions) + { + var (left, top, right, bottom) = ClipRegion(region, cols, rows); + if (left >= right || top >= bottom) continue; + + for (int y = top; y < bottom; y++) + { + int rowOffset = y * cols; + for (int x = left; x < right; x++) + { + int idx = rowOffset + x; + data[idx] = c0; + data[planeSize + idx] = c1; + data[2 * planeSize + idx] = c2; + } + } + } + } + + private static (int left, int top, int right, int bottom) ClipRegion(MaskRegion region, int imageWidth, int imageHeight) + { + int left = Math.Max(0, region.X); + int top = Math.Max(0, region.Y); + int right = Math.Min(imageWidth, region.X + region.Width); + int bottom = Math.Min(imageHeight, region.Y + region.Height); + return (left, top, right, bottom); + } +} + + + public static class DicomSortHelper { /// diff --git a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml index c979954d9..d0da72e65 100644 --- a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml +++ b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml @@ -15904,7 +15904,13 @@ - + + + 遮挡影像 + + + + 设置生命周期 @@ -16146,6 +16152,69 @@ + + + 要处理的帧,null 或空表示全部帧。 + + + + + 灰度图像的遮挡像素值,默认 0。 + 注意:这表示写入的原始像素值,不保证视觉上一定为黑色。 + + + + + 彩色图像的遮挡值,默认 [0,0,0]。 + 对 RGB 表示黑色;对 YBR 则表示三个分量都写 0。 + + + + + 严格保持原始 TransferSyntax。 + 如果无法重新编码到原语法,则抛异常。 + + + + + 是否更新 BurnedInAnnotation 为 NO。 + + + + + 验证dicom tag + + + + + + + 转为工作用的未压缩 DICOM + + + + + + + + + 转回原始 TransferSyntax + + + + + + + + + + + 修改像素 + + + + + DICOM Slice 排序(IPP + IOP) diff --git a/IRaCIS.Core.Application/TestService.cs b/IRaCIS.Core.Application/TestService.cs index 4dc4dbef4..119543e55 100644 --- a/IRaCIS.Core.Application/TestService.cs +++ b/IRaCIS.Core.Application/TestService.cs @@ -121,6 +121,43 @@ namespace IRaCIS.Core.Application.Service } + /// + /// 遮挡影像 + /// + /// + [AllowAnonymous] + public async Task MaskImage() + { + + var sourceDir = @"D:\images"; + var targetDir = @"D:\images\after"; + + Directory.CreateDirectory(targetDir); + var regions = new[] + { + new MaskRegion(0, 0, 200, 200) + }; + var options = new DicomMaskOptions + { + StrictKeepTransferSyntax = true + }; + foreach (var inputPath in Directory.EnumerateFiles(sourceDir, "*.dcm", SearchOption.TopDirectoryOnly)) + { + var relativePath = Path.GetRelativePath(sourceDir, inputPath); + var outputPath = Path.Combine(targetDir, relativePath); + var outputFolder = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputFolder)) + { + Directory.CreateDirectory(outputFolder); + } + await using var input = File.OpenRead(inputPath); + await using var output = File.Create(outputPath); + await DicomPixelMasker.MaskAsync(input, output, regions, options); + Console.WriteLine($"Done: {relativePath}"); + } + + return ResponseOutput.Ok(); + } /// /// 设置生命周期 @@ -128,7 +165,7 @@ namespace IRaCIS.Core.Application.Service /// /// [AllowAnonymous] - public async Task SetTrialLifeCycel(ModelVerifyCommand modelVerify) + public async Task SetTrialLifeCycel() { var trialIdList = _trialRepository.Where(t => t.TrialStatusStr == StaticData.TrialState.TrialCompleted).Select(t => t.Id).ToList();