修改异常取信息
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user