65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIcon
emptyStarIcon
starIcon
emptyStarIconemptyStarIcon

2.87/5 (4投票s)

2023 年 6 月 26 日

CPOL

4分钟阅读

viewsIcon

6204

downloadIcon

391

飞盘是一个简单的 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标准的各种方面的更多信息:

关于 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 存储库以添加功能或修复问题。

历史

  • 这是首次发布的版本。
© . All rights reserved.