修改异常取信息

This commit is contained in:
2026-04-13 15:38:22 +08:00
parent d262057a73
commit cca587fb17
5 changed files with 645 additions and 5 deletions
@@ -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
{
/// <summary>
/// 要处理的帧,null 或空表示全部帧。
/// </summary>
public IReadOnlyCollection<int>? FrameIndices { get; init; }
/// <summary>
/// 灰度图像的遮挡像素值,默认 0。
/// 注意:这表示写入的原始像素值,不保证视觉上一定为黑色。
/// </summary>
public ushort GrayscaleMaskValue { get; init; } = 0;
/// <summary>
/// 彩色图像的遮挡值,默认 [0,0,0]。
/// 对 RGB 表示黑色;对 YBR 则表示三个分量都写 0。
/// </summary>
public byte[] ColorMaskValue { get; init; } = new byte[] { 0, 0, 0 };
/// <summary>
/// 严格保持原始 TransferSyntax。
/// 如果无法重新编码到原语法,则抛异常。
/// </summary>
public bool StrictKeepTransferSyntax { get; init; } = true;
/// <summary>
/// 是否更新 BurnedInAnnotation 为 NO。
/// </summary>
public bool UpdateBurnedInAnnotationToNo { get; init; } = false;
}
public static class DicomPixelMasker
{
public static async Task<MemoryStream> MaskAsync(
Stream input,
IEnumerable<MaskRegion> 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<MaskRegion> 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<int>(DicomTag.Rows);
var cols = originalDataset.GetSingleValue<int>(DicomTag.Columns);
var bitsAllocated = originalDataset.GetSingleValue<int>(DicomTag.BitsAllocated);
var bitsStored = originalDataset.GetSingleValueOrDefault(DicomTag.BitsStored, bitsAllocated);
var samplesPerPixel = originalDataset.GetSingleValue<int>(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;
}
/// <summary>
/// 验证dicom tag
/// </summary>
/// <param name="dataset"></param>
/// <exception cref="NotSupportedException"></exception>
private static void ValidateDataset(DicomDataset dataset)
{
if (!dataset.Contains(DicomTag.PixelData))
throw new NotSupportedException("DICOM dataset does not contain Pixel Data.");
_ = dataset.GetSingleValue<int>(DicomTag.Rows);
_ = dataset.GetSingleValue<int>(DicomTag.Columns);
_ = dataset.GetSingleValue<int>(DicomTag.BitsAllocated);
_ = dataset.GetSingleValue<int>(DicomTag.SamplesPerPixel);
}
/// <summary>
/// 转为工作用的未压缩 DICOM
/// </summary>
/// <param name="sourceFile"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static async Task<DicomFile> 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);
}
}
/// <summary>
/// 转回原始 TransferSyntax
/// </summary>
/// <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 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;
}
}
/// <summary>
/// 修改像素
/// </summary>
/// <param name="dataset"></param>
/// <param name="regions"></param>
/// <param name="options"></param>
private static void MaskPixelDataInPlace(
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 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 newFrames = new List<IByteBuffer>(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<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 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;
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<MaskRegion> 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<MaskRegion> 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<MaskRegion> 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<MaskRegion> 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<MaskRegion> 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
{
/// <summary>