飞盘 - 一个简单的 DICOM 编辑器 (Frisbee - A Simple DICOM Editor)






2.87/5 (4投票s)
飞盘是一个简单的 DICOM 编辑器,有助于编辑 DICOM 文件
引言
DICOM(医学数字成像和通信)是一种标准,用于超声、X射线、CT、MRI、IGT等设备来存储扫描的图像。简单来说,DICOM图像包含一个头部和像素数据。头部包含图像的属性,如患者的人口统计信息(ID、姓名、年龄、出生日期、性别等)以及定义所执行检查的其他属性。像素数据包含扫描的身体部位的图像。DICOM标签以二进制格式存储在文件中。
DICOM图像属性具有一个基于典型成像工作流程的层次结构。患者到医院就诊,需要唯一标识患者,因此需要患者ID、姓名、年龄、出生日期、性别等。这构成了患者属性。现在,患者接受一项检查,会创建一个包含一系列图像的研究。假设在一次就诊中,患者接受了超声和X射线检查,那么将只有一项研究,而超声和X射线将有不同的系列。每次新的就诊都会创建一个新的研究。
下图显示了DICOM文件的结构(来自NEMA网站)。每个DICOM文件对应每次扫描生成的图像。在特定的检查过程中,可能会生成多个具有不同扫描设置的此类图像。
除了患者的人口统计信息,还有许多其他标签定义了创建图像的过程。例如,有定义研究(study instance UID
)、系列(series instance UID
)、图像(SOP instance UID
)等的标签。有定义像素数据属性的标签。同样,有一个标签代表像素数据本身。还有一组称为“序列”的标签。这是一个特定序列标签下的标签集合,例如,“Referenced Series Sequences”,它捕获了特定于系列的属性,如Series Date、Series Time、Series Instance UID等。此外,一个序列还可以包含另一个序列。
下图显示了DICOM标签的编码方式(来自NEMA网站)。标签是一个唯一的数字,标识标签(组号+元素号 -> 16位),VR(值表示 -> 双字节字符串)是存储的数据类型,值长度是值字段的字节大小(取决于VR为16位或32位),值字段包含实际的标签值(偶数字节)。
以上是快速的总结。您可以从以下资源阅读有关DICOM标准的各种方面的更多信息:
- https://dicom.nema.org/medical/dicom/current/output/html/part01.html
- https://dicomiseasy.blogspot.com/
关于 Frisbee DICOM 编辑器
此工具是一个基于 C# WPF 的应用程序。它可以帮助查看和编辑 DICOM 头部以及更新像素数据。它使用 fo-dicom
库来读取和写入 DICOM 文件。fo-dicom
是一个开源库,在 GitHub 上共享: https://github.com/fo-dicom/fo-dicom。
UI 基于 WPF,并使用 HandyControl
来提供更丰富的网格和其他 UI 元素。
Frisbee 的最新版本可以在这里找到:
UI 主要有四个显示区域:DICOM 头部显示、序列显示、图像显示以及下面的图像属性显示。可以通过单击“Value
”列并编辑值来编辑 DICOM 标签值。这在所有区域都可用,如头部显示、序列属性显示和图像属性。此外,可以通过单击网格中的复选框并按下“交叉”按钮来选择项目,从而在所有这些位置删除标签。除此之外,在图像显示区域,可以通过浏览新图像并相应地选择图像属性来更新像素数据。
从目录加载图像后,可以单击前进按钮移至目录中的下一个图像,或单击后退按钮查看前一个图像。
标签显示设计为一个可重用的 WPF 用户控件:DicomTagView
。它封装了 DICOM 标签显示所需的所有功能,如网格、更新和删除功能。 wherever the tags are displayed,都会使用此控件。其余代码对于任何 WPF MVVM 应用程序来说都相当直接。
DicomTagView.xaml
<UserControl x:Class="FrisbeeDicomEditor.Views.DicomTagView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:FrisbeeDicomEditor.Views"
xmlns:hc="https://handyorg.github.io/handycontrol"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
x:Name="DicomTagViewControl">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
Height="30" Background="#326CF3" BorderBrush="#326CF3" BorderThickness="1">
<TextBlock x:Name="header" Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:DicomTagView}},
Path=Header}" FontSize="12"/>
</Border>
<hc:SearchBar x:Name="searchBar" Grid.Column="1" Margin="5 0 0 0"
HorizontalAlignment="Right" Width="160" IsRealTime="True"
Command="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:DicomTagView}},
Path=SearchTextChangedCommand}"
CommandParameter="{Binding Text,RelativeSource=
{RelativeSource Self}}"/>
<Button x:Name="deleteButton" Grid.Row="0" Grid.Column="2"
Width="50" Padding="16,3" Margin="5"
Style="{StaticResource ButtonDanger.Small}"
hc:IconElement.Geometry="{StaticResource DeleteGeometry}"
Command="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:DicomTagView}}, Path=DeleteDicomItemCommand}"
CommandParameter="{Binding RelativeSource=
{RelativeSource FindAncestor, AncestorType={x:Type local:DicomTagView}},
Path=DeleteDicomItemCommandParam}"/>
<DataGrid x:Name="dataGrid" Grid.Row="1" Grid.Column="0"
Grid.RowSpan="4" Grid.ColumnSpan="3"
ItemsSource="{Binding RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type local:DicomTagView}}, Path=DicomAttributes}"
AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridCheckBoxColumn Binding="{Binding IsSelected,
UpdateSourceTrigger=PropertyChanged}"/>
<DataGridTextColumn Header="Tag" IsReadOnly="True"
Binding="{Binding DicomTag}"/>
<DataGridTextColumn Header="VR" IsReadOnly="True"
Binding="{Binding DicomVR}"/>
<DataGridTextColumn Header="Value"
Binding="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Width="{Binding RelativeSource=
{RelativeSource FindAncestor,
AncestorType={x:Type local:DicomTagView}},
Path=ValueColumnWidth}" MaxWidth="600">
<DataGridTextColumn.ElementStyle>
<Style>
<Setter Property="TextBlock.TextWrapping"
Value="WrapWithOverflow" />
<Setter Property="TextBlock.TextAlignment" Value="Left"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Description" IsReadOnly="True"
Binding="{Binding Description}" Width="400" MaxWidth="600">
<DataGridTextColumn.ElementStyle>
<Style>
<Setter Property="TextBlock.TextWrapping"
Value="WrapWithOverflow" />
<Setter Property="TextBlock.TextAlignment" Value="Left"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
Using the Code
此工具的代码在 GitHub 上共享: https://github.com/sudheeshps/FrisbeeDicomEditor。
加载和保存 DICOM 文件的核心代码是 DicomDataService
类。
public class DicomDataService : ObservableObject
{
private DicomDataset _dataset;
private List<string> _files;
private string _currentDir;
private int _fileIndex = 0;
public EventHandler<DicomFileStateEventArgs> FileOpenSuccess;
public EventHandler<DicomFileStateEventArgs> FileOpenFailed;
public EventHandler<DicomFileStateEventArgs> FileSaveSuccess;
public EventHandler<DicomFileStateEventArgs> FileSaveFailed;
public EventHandler<DicomFileStateEventArgs> ReplaceImageSuccess;
public EventHandler<DicomFileStateEventArgs> ReplaceImageFailed;
public EventHandler<DicomDatasetLoadStartedEventArgs> DicomDatasetLoadStarted;
public EventHandler<DicomItemReadEventArgs> DicomItemRead;
public EventHandler<DicomDatasetLoadCompletedArgs> DicomDatasetLoadCompleted;
public ObservableCollection<Models.DicomItem>
DicomItems { get; } = new ObservableCollection<Models.DicomItem>();
public ObservableCollection<Models.DicomItem>
ImageAttributes { get; } = new ObservableCollection<Models.DicomItem>();
private ObservableCollection<TreeViewItem> _sequences =
new ObservableCollection<TreeViewItem>();
public ObservableCollection<TreeViewItem> Sequences
{
get => _sequences;
set => SetProperty(ref _sequences, value);
}
public async Task<bool> LoadDicomFileAsync(string fileName)
{
try
{
using (var fileStream =
new FileStream(fileName, FileMode.Open, FileAccess.Read))
{
var dicomFile = await DicomFile.OpenAsync
(fileStream, FileReadOption.ReadAll);
if (dicomFile == null)
{
FileOpenFailed?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName });
return false;
}
_dataset = dicomFile.Dataset.Clone();
LoadDicomDataset();
FileOpenSuccess?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName });
LoadFilesInDirectory(fileName);
return true;
}
}
catch (Exception ex)
{
FileOpenFailed?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName, Exception = ex });
return false;
}
}
public async Task<bool> LoadNextFile()
{
if (_files.Count == 1)
{
return true;
}
if (_fileIndex == _files.Count - 1)
{
_fileIndex = 0;
}
return await LoadDicomFileAsync(_files[_fileIndex++]);
}
public async Task<bool> LoadPreviousFile()
{
if (_files.Count == 1)
{
return true;
}
if (_fileIndex == 0)
{
_fileIndex = _files.Count - 1;
}
return await LoadDicomFileAsync(_files[--_fileIndex]);
}
public async Task<bool> SaveDicomFileAsync(string fileName)
{
try
{
var dicomFile = new DicomFile(_dataset);
await dicomFile.SaveAsync(fileName);
FileSaveSuccess?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName });
return true;
}
catch (Exception ex)
{
FileSaveFailed?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName, Exception = ex });
return false;
}
}
public void ReplacePixelData
(string fileName, SelectedImageInfo selectedImageInfo)
{
try
{
var bitmap = new Bitmap(fileName);
var imageFormat = GetImageFormat(fileName);
var pixels = GetPixels(bitmap, imageFormat,
out var rows, out var columns);
var buffer = new MemoryByteBuffer(pixels);
AddOrUpdatePixelTags(selectedImageInfo, rows, columns);
AddPixelData(selectedImageInfo, rows, columns, buffer);
LoadDicomDataset();
ReplaceImageSuccess?.Invoke(this, new DicomFileStateEventArgs()
{ FileName = fileName });
}
catch (Exception ex)
{
ReplaceImageFailed?.Invoke(this, new DicomFileStateEventArgs()
{ Exception = ex });
}
}
private void LoadFilesInDirectory(string fileName)
{
var dir = Path.GetDirectoryName(fileName);
if (_currentDir != dir)
{
_files = Directory.GetFiles(dir).ToList();
_fileIndex = 0;
_currentDir = dir;
}
}
private void AddPixelData(SelectedImageInfo selectedImageInfo,
int rows, int columns, MemoryByteBuffer buffer)
{
var pixelData = DicomPixelData.Create(_dataset, true);
pixelData.BitsStored = selectedImageInfo.BitsStored;
pixelData.SamplesPerPixel = selectedImageInfo.SamplesPerPixel;
pixelData.HighBit = selectedImageInfo.HighBit;
pixelData.PhotometricInterpretation =
selectedImageInfo.PhotometricInterpretation;
pixelData.PixelRepresentation = selectedImageInfo.PixelRepresentation;
pixelData.PlanarConfiguration = selectedImageInfo.PlanarConfiguration;
pixelData.Height = (ushort)rows;
pixelData.Width = (ushort)columns;
pixelData.AddFrame(buffer);
}
private void AddOrUpdatePixelTags(SelectedImageInfo selectedImageInfo,
int rows, int columns)
{
_dataset.AddOrUpdate(DicomTag.PhotometricInterpretation,
selectedImageInfo.PhotometricInterpretation.Value);
_dataset.AddOrUpdate(DicomTag.Rows, (ushort)rows);
_dataset.AddOrUpdate(DicomTag.Columns, (ushort)columns);
_dataset.AddOrUpdate(DicomTag.BitsAllocated,
(ushort)selectedImageInfo.BitsAllocated);
}
private System.Drawing.Imaging.ImageFormat GetImageFormat(string fileName)
{
var fileExtension = Path.GetExtension(fileName);
switch (fileExtension)
{
case ".jpg":
case ".jpeg": return System.Drawing.Imaging.ImageFormat.Jpeg;
case ".bmp": return System.Drawing.Imaging.ImageFormat.Bmp;
case ".png": return System.Drawing.Imaging.ImageFormat.Png;
}
return null;
}
private static byte[] GetPixels
(Bitmap bitmap, System.Drawing.Imaging.ImageFormat imageFormat,
out int rows, out int columns)
{
using (var stream = new MemoryStream())
{
bitmap.Save(stream, imageFormat);
rows = bitmap.Height;
columns = bitmap.Width;
return stream.ToArray();
}
}
private void LoadDicomDataset()
{
DicomDatasetLoadStarted?.Invoke(this,
new DicomDatasetLoadStartedEventArgs() { DicomDataset = _dataset });
foreach (var dataItem in _dataset)
{
var dicomDataType = DicomItemType.Normal;
if (dataItem.Tag == DicomTag.Rows || dataItem.Tag == DicomTag.Columns ||
dataItem.Tag == DicomTag.BitsAllocated ||
dataItem.Tag == DicomTag.PhotometricInterpretation)
{
dicomDataType = DicomItemType.ImageAttribute;
}
if (dataItem.ValueRepresentation == DicomVR.SQ)
{
dicomDataType = DicomItemType.SequenceItem;
}
DicomItemRead?.Invoke(this, new DicomItemReadEventArgs()
{
DicomDataset = _dataset,
DicomItem = dataItem,
DicomItemType = dicomDataType
});
}
DicomDatasetLoadCompleted?.Invoke(this,
new DicomDatasetLoadCompletedArgs() { DicomDataset = _dataset });
if (_dataset.Contains(DicomTag.PixelData))
{
DicomItemRead?.Invoke(this, new DicomItemReadEventArgs()
{
DicomDataset = _dataset,
DicomItemType = DicomItemType.PixelData
});
}
}
}
该应用程序基于 WPF MVVM 模式设计。fo-dicom
库要求应用程序构建为 32 位或 64 位平台。仅在 64 位 Windows 上进行了测试。因此,在构建时需要选择 x64 平台。
关注点
该工具支持编辑和删除标签、更新像素数据等基本功能。但是,以下功能使其功能更加强大:
- 添加标签
- 显示多帧图像
- 等等。
测试仅使用可免费使用的测试数据进行。因此,关于使用不同测试 DICOM 图像的反馈将是一个很好的证明。欢迎提交 PR 到 GitHub 存储库以添加功能或修复问题。
历史
- 这是首次发布的版本。