Merge branch 'Test_IRC_Net8' of https://gitea.frp.extimaging.com/XCKJ/irc-netcore-api into Test_IRC_Net8
continuous-integration/drone/push Build is failing Details

Test_IRC_Net10
he 2026-04-13 15:43:02 +08:00
commit e430c40b99
5 changed files with 645 additions and 5 deletions

View File

@ -150,7 +150,7 @@ public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger<FileSync
{
log.JobState = jobState.FAILED;
log.Msg = ex.Message[..300];
log.Msg = ex.Message.Length > 300 ? ex.Message.Substring(0, 300) : ex.Message;
}
log.EndTime = DateTime.Now;

View File

@ -143,7 +143,7 @@ public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger<FileSync
{
log.JobState = jobState.FAILED;
log.Msg = ex.Message[..300];
log.Msg = ex.Message.Length > 300 ? ex.Message.Substring(0, 300) : ex.Message;
}
log.EndTime = DateTime.Now;

View File

@ -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>

View File

@ -15904,7 +15904,13 @@
<param name="outEnrollTime"></param>
<returns></returns>
</member>
<member name="M:IRaCIS.Core.Application.Service.TestService.SetTrialLifeCycel(IRaCIS.Core.Application.Service.TestService.ModelVerifyCommand)">
<member name="M:IRaCIS.Core.Application.Service.TestService.MaskImage">
<summary>
遮挡影像
</summary>
<returns></returns>
</member>
<member name="M:IRaCIS.Core.Application.Service.TestService.SetTrialLifeCycel">
<summary>
设置生命周期
</summary>
@ -16146,6 +16152,69 @@
<param name="prefix"></param>
<returns></returns>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.FrameIndices">
<summary>
要处理的帧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]。
对 RGB 表示黑色;对 YBR 则表示三个分量都写 0。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.StrictKeepTransferSyntax">
<summary>
严格保持原始 TransferSyntax。
如果无法重新编码到原语法,则抛异常。
</summary>
</member>
<member name="P:IRaCIS.Core.Application.Helper.DicomMaskOptions.UpdateBurnedInAnnotationToNo">
<summary>
是否更新 BurnedInAnnotation 为 NO。
</summary>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomPixelMasker.ValidateDataset(FellowOakDicom.DicomDataset)">
<summary>
验证dicom tag
</summary>
<param name="dataset"></param>
<exception cref="T:System.NotSupportedException"></exception>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomPixelMasker.EnsureUncompressedAsync(FellowOakDicom.DicomFile,System.Threading.CancellationToken)">
<summary>
转为工作用的未压缩 DICOM
</summary>
<param name="sourceFile"></param>
<param name="cancellationToken"></param>
<returns></returns>
<exception cref="T:System.InvalidOperationException"></exception>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomPixelMasker.ReEncodeToOriginalTransferSyntaxAsync(FellowOakDicom.DicomFile,FellowOakDicom.DicomTransferSyntax,System.Boolean,System.Threading.CancellationToken)">
<summary>
转回原始 TransferSyntax
</summary>
<param name="uncompressedFile"></param>
<param name="originalTransferSyntax"></param>
<param name="strictKeepTransferSyntax"></param>
<param name="cancellationToken"></param>
<returns></returns>
<exception cref="T:System.InvalidOperationException"></exception>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomPixelMasker.MaskPixelDataInPlace(FellowOakDicom.DicomDataset,System.Collections.Generic.IReadOnlyList{IRaCIS.Core.Application.Helper.MaskRegion},IRaCIS.Core.Application.Helper.DicomMaskOptions)">
<summary>
修改像素
</summary>
<param name="dataset"></param>
<param name="regions"></param>
<param name="options"></param>
</member>
<member name="M:IRaCIS.Core.Application.Helper.DicomSortHelper.SortSlices``1(System.Collections.Generic.IEnumerable{``0},System.Func{``0,System.String},System.Func{``0,System.String},System.Func{``0,System.Nullable{System.Int32}})">
<summary>
DICOM Slice 排序IPP + IOP

View File

@ -121,6 +121,43 @@ namespace IRaCIS.Core.Application.Service
}
/// <summary>
/// 遮挡影像
/// </summary>
/// <returns></returns>
[AllowAnonymous]
public async Task<IResponseOutput> 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();
}
/// <summary>
/// 设置生命周期
@ -128,7 +165,7 @@ namespace IRaCIS.Core.Application.Service
/// <param name="modelVerify"></param>
/// <returns></returns>
[AllowAnonymous]
public async Task<IResponseOutput> SetTrialLifeCycel(ModelVerifyCommand modelVerify)
public async Task<IResponseOutput> SetTrialLifeCycel()
{
var trialIdList = _trialRepository.Where(t => t.TrialStatusStr == StaticData.TrialState.TrialCompleted).Select(t => t.Id).ToList();