目录
介绍
DICOM(医学数字成像和通信)是用于超声、X射线、CT、MRI、IGT等模态设备以存储扫描图像的标准。简单来说,DICOM图像具有标题和像素数据。标题包含图像的属性,如患者人口统计数据,如ID、姓名、年龄、出生日期、性别等,以及定义所进行研究的其他属性。像素数据包含扫描的身体部位的图片。DICOM标记以二进制格式存储在文件中。
DICOM图像属性具有基于典型成像工作流程的层次结构。患者就诊的医院要求对患者进行唯一标识,这需要患者ID、姓名、年龄、出生日期、性别等。这构成了患者属性。现在,患者接受了一项手术,该手术导致沿着一系列图像创建研究。假设在一次就诊中,患者接受了超声和X射线检查,那么超声和X射线将只有一项研究和不同的系列。每次新的访问都会创建一个新的研究。
下图显示了DICOM文件的结构(取自NEMA网站)。每个DICOM文件对应于每次扫描生成的图像。在特定检查期间,可以使用不同的扫描设置生成多个此类图像。
除了患者人口统计数据外,还有许多其他标签定义了创建图像的程序。例如,有标识研究(研究实例UID)、系列(系列实例UID)、图像(SOP实例UID)等的标签。有一些标签定义像素数据的属性。同样,也有表示像素数据本身的标签。还有另一组称为“序列”的标签。这是特定序列标签下的标签集合,例如“引用的系列序列”,它捕获特定于序列的属性,如序列日期、序列时间、序列实例UID等。此外,一个序列可以包含另一个序列。
下图显示了DICOM标签的编码方式(取自NEMA网站)。标签是标识标签的唯一编号(组号+元素号 -> 16 位),VR(值表示 -> 双字节字符串)是存储的数据类型,value长度是值字段(16或32位,取决于VR)的大小(以字节为单位),value 字段包含实际的标签值(偶数字节)。
这是一个明确的总结。您可以从以下资源中阅读有关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标记值。这在标题显示、序列属性显示和图像属性等所有领域都是可能的。此外,可以通过单击网格中的复选框并按“cross”按钮来选择项目来删除所有这些位置的标签。除此之外,在图像显示区域中,可以通过浏览新图像并相应地选择图像属性来更新像素数据。
从目录加载图像后,可以单击“前进”按钮移动到目录中的下一个图像,或单击“后退”按钮查看上一个图像。
标记显示设计为可重用的WPF用户控件:DicomTagView。它将DICOM标签显示所需的所有功能包装为网格、更新和删除功能。这用于显示标签的所有位置。对于任何基于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>
使用代码
此工具的代码共享@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图像的反馈将是一个很好的证明。感谢GitHub存储库的PR以添加功能或修复问题。
https://www.codeproject.com/Articles/5363606/Frisbee-A-Simple-DICOM-Editor