Compare commits

..

No commits in common. "05b2ab75a3aafe26e908395005de94ecde045312" and "334e8f62677b86c26a632070bbc2da9b78a67952" have entirely different histories.

4 changed files with 154 additions and 211 deletions

View File

@ -129,7 +129,7 @@ public sealed class DicomMaskOptions
/// 灰度图像的遮挡像素值,默认 0。
/// 注意:这表示写入的原始像素值,不保证视觉上一定为黑色。
/// </summary>
//public ushort GrayscaleMaskValue { get; init; } = 0;
public ushort GrayscaleMaskValue { get; init; } = 0;
/// <summary>
/// 彩色图像的遮挡值,默认 [0,0,0]。
@ -147,19 +147,6 @@ public sealed class DicomMaskOptions
/// 是否更新 BurnedInAnnotation 为 NO。
/// </summary>
public bool UpdateBurnedInAnnotationToNo { get; init; } = false;
/// <summary>
/// 是否启用自动灰度遮挡值。
/// true 时,根据 PhotometricInterpretation + BitsStored + PixelRepresentation 自动选择更合理的值。
/// </summary>
public bool AutoSelectGrayscaleMaskValue { get; init; } = true;
/// <summary>
/// 当 AutoSelectGrayscaleMaskValue=false 时,使用该值。
/// 当为 null 且 AutoSelectGrayscaleMaskValue=false 时,默认 0。
/// </summary>
public int? GrayscaleMaskValue { get; init; }
}
@ -201,7 +188,7 @@ public static class DicomPixelMasker
ValidateDataset(originalDataset);
var originalTs = originalFile.FileMetaInfo.TransferSyntax;
var originalTransferSyntax = originalFile.FileMetaInfo.TransferSyntax;
var originalPhotometric = originalDataset.GetSingleValueOrDefault(DicomTag.PhotometricInterpretation, string.Empty);
var rows = originalDataset.GetSingleValue<int>(DicomTag.Rows);
@ -211,32 +198,36 @@ public static class DicomPixelMasker
var samplesPerPixel = originalDataset.GetSingleValue<int>(DicomTag.SamplesPerPixel);
var pixelRepresentation = originalDataset.GetSingleValueOrDefault(DicomTag.PixelRepresentation, (ushort)0);
var planarConfiguration = originalDataset.GetSingleValueOrDefault(DicomTag.PlanarConfiguration, (ushort)0);
Console.WriteLine($"Rows={rows}, Cols={cols}, BitsAllocated={bitsAllocated}, BitsStored={bitsStored}, SamplesPerPixel={samplesPerPixel}, PixelRepresentation={pixelRepresentation}, PlanarConfiguration={planarConfiguration}");
//log?.Invoke($"Rows={rows}, Cols={cols}, BitsAllocated={bitsAllocated}, BitsStored={bitsStored}, SamplesPerPixel={samplesPerPixel}, PixelRepresentation={pixelRepresentation}, PlanarConfiguration={planarConfiguration}");
EnsureSupportedPhotometric(originalPhotometric, samplesPerPixel);
// 1. 转为工作用的未压缩 DICOM
var workingFile = await EnsureUncompressedAsync(originalFile, cancellationToken).ConfigureAwait(false);
var workingDataset = workingFile.Dataset;
// 2. 修改像素
MaskPixelDataInPlace(workingFile.Dataset, regionList, options);
MaskPixelDataInPlace(workingDataset, regionList, options);
// 3. 保持原 PhotometricInterpretation
if (!string.IsNullOrWhiteSpace(originalPhotometric))
{
workingFile.Dataset.AddOrUpdate(DicomTag.PhotometricInterpretation, originalPhotometric);
workingDataset.AddOrUpdate(DicomTag.PhotometricInterpretation, originalPhotometric);
}
// 4. 可选更新 BurnedInAnnotation
if (options.UpdateBurnedInAnnotationToNo)
{
workingFile.Dataset.AddOrUpdate(DicomTag.BurnedInAnnotation, "NO");
workingDataset.AddOrUpdate(DicomTag.BurnedInAnnotation, "NO");
}
// 5. 编码回原始传输语法
// 5. 转回原始 TransferSyntax
var finalFile = await ReEncodeToOriginalTransferSyntaxAsync(
workingFile,
originalTs,
originalTransferSyntax,
options.StrictKeepTransferSyntax,
cancellationToken).ConfigureAwait(false);
finalFile.FileMetaInfo.TransferSyntax = originalTs;
finalFile.FileMetaInfo.TransferSyntax = originalTransferSyntax;
if (output.CanSeek)
output.SetLength(0);
@ -245,7 +236,6 @@ public static class DicomPixelMasker
if (output.CanSeek)
output.Position = 0;
}
/// <summary>
@ -264,37 +254,6 @@ public static class DicomPixelMasker
_ = dataset.GetSingleValue<int>(DicomTag.SamplesPerPixel);
}
private static void EnsureSupportedPhotometric(string photometric, int samplesPerPixel, Action<string>? log = null)
{
if (samplesPerPixel == 1)
{
if (string.Equals(photometric, "MONOCHROME1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(photometric, "MONOCHROME2", StringComparison.OrdinalIgnoreCase))
{
return;
}
throw new NotSupportedException($"Unsupported grayscale PhotometricInterpretation: {photometric}");
}
if (samplesPerPixel == 3)
{
if (string.Equals(photometric, "RGB", StringComparison.OrdinalIgnoreCase) ||
string.Equals(photometric, "YBR_FULL", StringComparison.OrdinalIgnoreCase))
{
return;
}
// 对 YBR_FULL_422 不建议直接按当前布局改
if (string.Equals(photometric, "YBR_FULL_422", StringComparison.OrdinalIgnoreCase) ||
string.Equals(photometric, "YBR_PARTIAL_422", StringComparison.OrdinalIgnoreCase) ||
string.Equals(photometric, "YBR_PARTIAL_420", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException(
$"PhotometricInterpretation={photometric} uses subsampling layout and is not safely supported by this pixel masking implementation.");
}
throw new NotSupportedException($"Unsupported color PhotometricInterpretation: {photometric}");
}
throw new NotSupportedException($"Unsupported SamplesPerPixel={samplesPerPixel}");
}
/// <summary>
/// 转为工作用的未压缩 DICOM
/// </summary>
@ -329,35 +288,59 @@ public static class DicomPixelMasker
/// <summary>
/// 转回原始 TransferSyntax
/// </summary>
/// <param name="workingFile"></param>
/// <param name="uncompressedFile"></param>
/// <param name="originalTransferSyntax"></param>
/// <param name="strictKeepTransferSyntax"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static async Task<DicomFile> ReEncodeToOriginalTransferSyntaxAsync(
DicomFile workingFile,
DicomTransferSyntax originalTransferSyntax,
bool strictKeepTransferSyntax,
CancellationToken cancellationToken)
DicomFile uncompressedFile,
DicomTransferSyntax originalTransferSyntax,
bool strictKeepTransferSyntax,
CancellationToken cancellationToken)
{
if (workingFile.FileMetaInfo.TransferSyntax == originalTransferSyntax)
return workingFile;
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 = workingFile.FileMetaInfo.TransferSyntax;
var currentTs = uncompressedFile.FileMetaInfo.TransferSyntax;
var transcoder = new DicomTranscoder(currentTs, originalTransferSyntax);
return await Task.Run(() => transcoder.Transcode(workingFile), cancellationToken).ConfigureAwait(false);
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 TransferSyntax {originalTransferSyntax.UID.UID}.",
$"Failed to re-encode DICOM back to original compressed TransferSyntax {originalTransferSyntax.UID.UID}.",
ex);
}
return workingFile;
return uncompressedFile;
}
}
@ -368,66 +351,77 @@ public static class DicomPixelMasker
/// <param name="regions"></param>
/// <param name="options"></param>
private static void MaskPixelDataInPlace(
DicomDataset dataset,
IReadOnlyList<MaskRegion> regions,
DicomMaskOptions options)
DicomDataset dataset,
IReadOnlyList<MaskRegion> regions,
DicomMaskOptions options)
{
var rows = dataset.GetSingleValue<int>(DicomTag.Rows);
var cols = dataset.GetSingleValue<int>(DicomTag.Columns);
var bitsAllocated = dataset.GetSingleValue<int>(DicomTag.BitsAllocated);
var bitsStored = dataset.GetSingleValueOrDefault(DicomTag.BitsStored, bitsAllocated);
var samplesPerPixel = dataset.GetSingleValue<int>(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 replacementFrames = new List<IByteBuffer>(frameCount);
var newFrames = new List<IByteBuffer>(frameCount);
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
var frame = pixelData.GetFrame(frameIndex);
var buffer = frame.Data;
var bytes = buffer.ToArray();
var data = frame.Data.ToArray();
if (framesToProcess.Contains(frameIndex))
{
ApplyMaskToFrame(
frameData: bytes,
rows: rows,
cols: cols,
bitsAllocated: bitsAllocated,
bitsStored: bitsStored,
samplesPerPixel: samplesPerPixel,
pixelRepresentation: pixelRepresentation,
photometric: photometric,
planarConfiguration: planarConfiguration,
regions: regions,
options: options);
data,
rows,
cols,
bitsAllocated,
samplesPerPixel,
pixelRepresentation,
photometric,
planarConfiguration,
regions,
options);
}
replacementFrames.Add(new MemoryByteBuffer(bytes));
newFrames.Add(new MemoryByteBuffer(data));
}
ReplacePixelDataFrames(dataset, pixelData, replacementFrames);
ReplacePixelDataFrames(dataset, pixelData, newFrames);
}
private static HashSet<int> ResolveFrames(int frameCount, IReadOnlyCollection<int>? frameIndices)
{
if (frameIndices == null || frameIndices.Count == 0)
return Enumerable.Range(0, frameCount).ToHashSet();
var result = new HashSet<int>();
foreach (var index in frameIndices)
{
if (index < 0 || index >= frameCount)
throw new ArgumentOutOfRangeException(nameof(frameIndices), $"Frame index {index} out of range [0, {frameCount - 1}]");
result.Add(index);
return Enumerable.Range(0, frameCount).ToHashSet();
}
var result = new HashSet<int>();
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<IByteBuffer> frames)
{
dataset.Remove(DicomTag.PixelData);
var newPixelData = DicomPixelData.Create(dataset, true);
//newPixelData.BitsAllocated = sourcePixelData.BitsAllocated;
newPixelData.BitsStored = sourcePixelData.BitsStored;
@ -438,6 +432,7 @@ public static class DicomPixelMasker
newPixelData.Height = sourcePixelData.Height;
newPixelData.Width = sourcePixelData.Width;
newPixelData.PhotometricInterpretation = sourcePixelData.PhotometricInterpretation;
foreach (var frame in frames)
{
newPixelData.AddFrame(frame);
@ -445,106 +440,63 @@ public static class DicomPixelMasker
}
private static void ApplyMaskToFrame(
byte[] frameData,
int rows,
int cols,
int bitsAllocated,
int bitsStored,
int samplesPerPixel,
ushort pixelRepresentation,
string photometric,
ushort planarConfiguration,
IReadOnlyList<MaskRegion> regions,
DicomMaskOptions options)
byte[] frameData,
int rows,
int cols,
int bitsAllocated,
int samplesPerPixel,
ushort pixelRepresentation,
string photometric,
ushort planarConfiguration,
IReadOnlyList<MaskRegion> regions,
DicomMaskOptions options)
{
if (samplesPerPixel == 1)
{
if (bitsAllocated == 8)
{
byte maskValue8 = ResolveGrayscaleMaskValue8(photometric, options);
ApplyMask_Grayscale8(frameData, rows, cols, regions, maskValue8);
//8 位灰度
ApplyMask_Grayscale8(frameData, rows, cols, regions, (byte)Math.Min(options.GrayscaleMaskValue, byte.MaxValue));
return;
}
// 16 位灰度
if (bitsAllocated == 16)
{
ushort maskValue16 = ResolveGrayscaleMaskValue16(
photometric,
bitsStored,
pixelRepresentation,
options);
ApplyMask_Grayscale16(frameData, rows, cols, regions, maskValue16);
ApplyMask_Grayscale16(frameData, rows, cols, regions, options.GrayscaleMaskValue);
return;
}
throw new NotSupportedException(
$"Unsupported grayscale image: BitsAllocated={bitsAllocated}, Photometric={photometric}");
throw new NotSupportedException($"Unsupported grayscale BitsAllocated={bitsAllocated}, Photometric={photometric}.");
}
//RGB 彩图
if (samplesPerPixel == 3)
{
if (bitsAllocated != 8)
{
throw new NotSupportedException(
$"Unsupported color image: SamplesPerPixel=3, BitsAllocated={bitsAllocated}, Photometric={photometric}");
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 PlanarConfiguration={planarConfiguration}, Photometric={photometric}.");
}
throw new NotSupportedException(
$"Unsupported format: SamplesPerPixel={samplesPerPixel}, BitsAllocated={bitsAllocated}, Photometric={photometric}");
}
private static byte ResolveGrayscaleMaskValue8(string photometric, DicomMaskOptions options)
{
if (!options.AutoSelectGrayscaleMaskValue)
{
var value = options.GrayscaleMaskValue ?? 0;
return (byte)Math.Clamp(value, byte.MinValue, byte.MaxValue);
}
if (string.Equals(photometric, "MONOCHROME1", StringComparison.OrdinalIgnoreCase))
return byte.MaxValue;
return byte.MinValue;
}
private static ushort ResolveGrayscaleMaskValue16(
string photometric,
int bitsStored,
ushort pixelRepresentation,
DicomMaskOptions options)
{
if (!options.AutoSelectGrayscaleMaskValue)
{
var value = options.GrayscaleMaskValue ?? 0;
return unchecked((ushort)value);
}
bool isSigned = pixelRepresentation == 1;
bool mono1 = string.Equals(photometric, "MONOCHROME1", StringComparison.OrdinalIgnoreCase);
bitsStored = Math.Clamp(bitsStored, 1, 16);
if (!isSigned)
{
ushort min = 0;
ushort max = bitsStored == 16
? ushort.MaxValue
: (ushort)((1 << bitsStored) - 1);
return mono1 ? max : min;
}
else
{
// signed range based on BitsStored
// minSigned = -(1 << (bitsStored - 1))
// maxSigned = (1 << (bitsStored - 1)) - 1
int minSigned = -(1 << (bitsStored - 1));
int maxSigned = (1 << (bitsStored - 1)) - 1;
short selected = mono1 ? (short)maxSigned : (short)minSigned;
return unchecked((ushort)selected);
}
$"Unsupported image format: SamplesPerPixel={samplesPerPixel}, BitsAllocated={bitsAllocated}, PixelRepresentation={pixelRepresentation}, Photometric={photometric}.");
}
private static void ApplyMask_Grayscale8(
byte[] data,
int rows,
@ -556,16 +508,18 @@ public static class DicomPixelMasker
{
var (left, top, right, bottom) = ClipRegion(region, cols, rows);
if (left >= right || top >= bottom) continue;
for (int y = top; y < bottom; y++)
{
int offset = y * cols;
int rowOffset = y * cols;
for (int x = left; x < right; x++)
{
data[offset + x] = maskValue;
data[rowOffset + x] = maskValue;
}
}
}
}
private static void ApplyMask_Grayscale16(
byte[] data,
int rows,
@ -577,19 +531,22 @@ public static class DicomPixelMasker
{
var (left, top, right, bottom) = ClipRegion(region, cols, rows);
if (left >= right || top >= bottom) continue;
for (int y = top; y < bottom; y++)
{
int offset = y * cols;
int rowOffset = y * cols;
for (int x = left; x < right; x++)
{
int pixelIndex = offset + 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,
@ -599,19 +556,23 @@ public static class DicomPixelMasker
{
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;
@ -619,6 +580,7 @@ public static class DicomPixelMasker
}
}
}
private static void ApplyMask_Color8_Planar(
byte[] data,
int rows,
@ -628,14 +590,18 @@ public static class DicomPixelMasker
{
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;
@ -649,10 +615,8 @@ public static class DicomPixelMasker
}
}
}
private static (int left, int top, int right, int bottom) ClipRegion(
MaskRegion region,
int imageWidth,
int imageHeight)
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);

View File

@ -1,14 +1,13 @@
using IdentityModel;
using Newtonsoft.Json;
using RestSharp;
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RestSharp;
using System.Net;
using System.Threading.Tasks;
using IdentityModel;
namespace IRaCIS.Core.Application.Helper.OtherTool;
@ -32,11 +31,6 @@ public static class WeComNotifier
public static async Task SendAlertAsync(string webhook, WeComAlert alert)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
await Task.CompletedTask;
}
try
{
var client = new RestClient();

View File

@ -16157,6 +16157,12 @@
要处理的帧null 或空表示全部帧。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.GrayscaleMaskValue">
<summary>
灰度图像的遮挡像素值,默认 0。
注意:这表示写入的原始像素值,不保证视觉上一定为黑色。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.ColorMaskValue">
<summary>
彩色图像的遮挡值,默认 [0,0,0]。
@ -16174,18 +16180,6 @@
是否更新 BurnedInAnnotation 为 NO。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.AutoSelectGrayscaleMaskValue">
<summary>
是否启用自动灰度遮挡值。
true 时,根据 PhotometricInterpretation + BitsStored + PixelRepresentation 自动选择更合理的值。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.GrayscaleMaskValue">
<summary>
当 AutoSelectGrayscaleMaskValue=false 时,使用该值。
当为 null 且 AutoSelectGrayscaleMaskValue=false 时,默认 0。
</summary>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomPixelMasker.ValidateDataset(FellowOakDicom.DicomDataset)">
<summary>
验证dicom tag
@ -16206,7 +16200,7 @@
<summary>
转回原始 TransferSyntax
</summary>
<param name="workingFile"></param>
<param name="uncompressedFile"></param>
<param name="originalTransferSyntax"></param>
<param name="strictKeepTransferSyntax"></param>
<param name="cancellationToken"></param>
@ -17212,17 +17206,17 @@
</member>
<member name="F:IRaCIS.Core.Application.ViewModel.AccessToDialogueEnum.Question">
<summary>
质疑
<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
</summary>
</member>
<member name="F:IRaCIS.Core.Application.ViewModel.AccessToDialogueEnum.Consistency">
<summary>
一致性核查
һ<EFBFBD><EFBFBD><EFBFBD>Ժ˲<EFBFBD>
</summary>
</member>
<member name="T:IRaCIS.Core.Application.ViewModel.CopyFrontAuditConfigItemDto">
<summary>
复制
<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
</summary>
</member>
<member name="T:IRaCIS.Core.Application.ViewModel.SystemNoticeView">

View File

@ -137,7 +137,10 @@ namespace IRaCIS.Core.Application.Service
{
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);
@ -149,19 +152,7 @@ namespace IRaCIS.Core.Application.Service
}
await using var input = File.OpenRead(inputPath);
await using var output = File.Create(outputPath);
try
{
await DicomPixelMasker.MaskAsync(input, output, regions);
}
catch(Exception ex)
{
// 跳过该文件
Console.WriteLine($"error: {ex.Message}");
}
await DicomPixelMasker.MaskAsync(input, output, regions, options);
Console.WriteLine($"Done: {relativePath}");
}