2014年9月15日月曜日

眼底用 DICOM ゲートウェイ

眼底用 DICOM ゲートウェイ

眼底カメラのDICOM Gatewayアプリを作成したので紹介します。

機能:

眼底カメラから得られた画像をDICOMサーバに送信する

装置:

  • 眼底装置: Canon CR-DGi
  • デジタルカメラ: Canon EOS 20D (眼底装置に付属)

開発環境:

  • Microsoft Visual Studio 2010
  • 使用言語: c#
  • 使用クラスライブラリ: fo-dicom, WIA, Canon SDK wrapper(EDSDK.cs)
現在使用中の眼底用DICOM Gateway は IIYAMA製で開発したものなのですが部署が既に存在しないためサポートを受けられない状況です。また OS が Windows XP なのでそちらの方もサポート対象外となっています。
今回更新時期と考え、眼底用DICOM Gateway を新たに開発することにしました。

内容:

大まかな流れは
  • デジタルカメラから画像を読み取る。
  • 画像を加工する。
  • 患者情報を取得する。
  • Dicom DataSetを作成する
  • Dicom 送信する。
となります。
以下詳細です。

1.画像の読み取り

デジタルカメラからの画像の読み取りは(1)Wi-Fiを使用する (2)WIA(Windows Imaging Acquisition)を使用する (3)Canon SDK を使用する、等の方法がありますが一長一短でした。

1.1.Wi-Fi

DeLOCK社製[CFカード→SDカード変換アダプター] と [Eye-Fi Pro]の組み合わせで Wi-Fi 経由で画像をPCに送ります。Eye-FiのホームページによるとCF-SDカードアダプターはサポート外とのことでしたが、こちらが使用した範囲では使用可能でした。画像読み取りの部分がノン・プログラムなので至極簡便。長時間使用しない場合 Eye-Fi Pro がスリープモードに入るためか転送に時間がかかる場合がありますが、実用範囲内です。

1.2.WIA

.net 標準のデジカメからの画像取得方法らしいです。当方ではUSBケーブル接続時の初回のイベントしか取得できませんでした。ケーブル接続した後に撮影した画像は取得できません。コントロールパネルのスキャナから参照しても取得できなかったので、そういう仕様なのか、あるいはドライバーのせいかもしれません。USBケーブル常時接続の眼底カメラ装置には不向きです。

1.3.Canon SDK  wrapper

米国向けにCanonからデジカメ用のSDKが公開されていますが、日本では不可のようです。ただ Canon のデジカメ所有者なら EOS Utility をインストールできるので、そのフォルダから dll を参照することが可能です。言わずもがな自己責任、使用はお勧めできません。今回は Canon SDK wrapper の EDSDK.cs を使用します。

2.DICOM変換

使用したクラスライブラリーはいつもお世話になっている fo-dicomを使用しました。fo-dicomはc#で無料で使える数少ないクラスライブラリーでは無いでしょうか。

以下がおおまかな流れです。

2.1.患者マスター・データベースから患者情報を取得する

  • 患者番号:  A12345    (数値も文字列として扱う)
  • ローマ字名前:  BYOUIN^TAROU    (スペースはキャロット ^ で)
  • 生年月日:  19990404    (yyyyMMdd形式です)
  • 性別:  M or F or O    (Oはその他)
  • 年齢:  036Y    (000Y形式)

2.2.画像ファイルを読み取る

  • pictureboxに画像を表示させる。
  • R,L,annotateなどの文字列を画像に追加表示。
  • width,height,sizeなどを変更して保存する。
  •     眼底カメラ画像の左右は黒単色なので切り取り正方形(width=height)に変更。

2.3.dicom tag の設定

  • SOPClassUID: SecondaryCaptureImageStrage (1.2.840.10008.5.1.4.1.1.7)
  • SOPInstanceUID: 固有値
  • StudyInstanceUID: 固有値
  • SeriesInstanceUID: 固有値
  • ConversionType: DI
  • Modality: XC
  • StudyDescription: FUNDOSCOPY
  • ImageLaterality: R or L
  • などなど.....

2.4.ピクセルデータの作成

  • red と blueを入れ替えてピクセルデータを作成

2.5.dicom 送信

2.6.ビジネスルールの追加(履歴登録など)

3.サンプルコード

3.1.WIA

WIAの情報があまり無いため手こずりました。
CommonDialog を使用してダイアログボックスから画像を開くコードは散見しましたが、クリックの回数が多くなるので直接デジカメからファイルをダウンロードすることにします。繰り返しになりますが、USB接続時のイベントしか取得できません。USB接続後に撮影された画像は取得できないので、毎回USBケーブルを再接続することになります。眼底カメラ用途では実用的ではありません。
netframework は fo-dicomを使用するため 4.0 を使用します。
※ FormatID, CommandIDなどは 相互運用型ナンチャラで .net 2.0 でしか使えないようなので直接文字列を設定しておきます。
参照設定のCOMタブからMicrosoft Windows Image Acquisition Library v2.0]を追加します。
private bool ReloadWia()
{
    Device device = null;
    DeviceManager dm = new DeviceManager();
    const string wiaFormatJPEG = "{B96B3CAE-0728-11D3-9D7B-0000F81EF32E}";
    const string wiaCommandSynchronize = "{9B26B7B2-ACAD-11D2-A093-00C04F72DC3C}";
    //
    foreach (DeviceInfo devInfo in dm.DeviceInfos)
    {
        if (devInfo.Type == WiaDeviceType.CameraDeviceType)
        {
            device = devInfo.Connect();
            break;
        }
    }
    if (device == null)
    {
        LogError("EOS 20Dに接続できません(WIA)", true);
        return false;
    }
    try
    {
        for (int i = 1; i < device.Items.Count + 1; i++)
        {
            string[] devNames = device.Items[i].ItemID.Split();
            if (!devNames[2].Contains("CANON"))
                continue;
            for (int j = 1; j < device.Items[i].Items.Count + 1; j++)
            {
                Item item = device.Items[i].Items[j];
                string[] names = item.ItemID.Split();
                WIA.ImageFile imageFile = (WIA.ImageFile)item.Transfer(wiaFormatJPEG);   //wiaFormatJPEG
                string fileName = Path.Combine(Env.ImagePath, names[3] + ".jpg");
                if(File.Exists(fileName))
                {
                    string tmpFileName=string.Empty;
                    for (int kk = 0; kk < 1000; kk++)
                    {
                        tmpFileName=Path.Combine(Env.ImagePath, Path.GetFileNameWithoutExtension(fileName)+"_"+ kk.ToString("000")+ ".jpg");
                        if (!File.Exists(tmpFileName))
                            break;
                    }
                    if (tmpFileName == fileName)
                    {LogError("[ファイル名を取得できません]: 重複エラー", true);return false; }
                    fileName = tmpFileName;
                }
                imageFile.SaveFile(fileName);   //ファイルの保存
                if (imageFile != null)
                    Marshal.ReleaseComObject(imageFile);
            }
        }
        //WAI からファイルを削除
        int k = 0;
        for (int i = 1; i < device.Items.Count + 1; i++)
        {
            string[] devNames = device.Items[i].ItemID.Split('\\');
            if (!devNames[2].Contains("CANON"))
                continue;
            for (int j = device.Items[i].Items.Count; j >= 1; j--)
            {
                device.Items[i].Items.Remove(j);
                k++;
            }
        }
        if (k > 0)
            device.ExecuteCommand(wiaCommandSynchronize);
    }
    catch (Exception ex)
    {
        LogError(ex.Message, true);
        return false;
    }
 
    return true;
}

3.2.Canon SDK wrapper (EDSDK)

先にインストールしておいた EOS Utility のフォルダから EDSDK.dll, EdsImage.dll をカレントディレクトリにコピーしておきます。
カメラに(1)接続してファイルを(2)ダウンロード後にカメラから(3)切断します。
カメラシャッター時にファイルをダウンロードしたかったのですが、 EdsSetObjectEventHandler の第一引数の inEvent の EDSDK.ObjectEvent_DirItemCreated をうまく捕まえることができませんでした。できたりできなかったりと再現性なしです。
また、第二引数の inRef に EdsGetDirectoryItemInfoのポインター(IntPtr)が入っているようネットで散見しますが EOS-20Dでは入っていませんでした。
したがって、今回はシャッター後に、ファンクションキーにてダウンロードする仕様にしました。

3.2.1.カメラに接続
private bool EosConnect()
{
    IntPtr cameraList;
    if (_eosCameraRef != IntPtr.Zero)
    {
        return true;
    }
    uint err;
    err = EDSDK.EdsInitializeSDK();
    // カメラリストの取得
    err = EDSDKLib.EDSDK.EdsGetCameraList(out cameraList);      
    if (err != EDSDK.EDS_ERR_OK)
    { LogError("カメラ・オブジェクトのリストを取得できません", true); return false; }
    int count = 0;
    err = EDSDK.EdsGetChildCount(cameraList, out count);
    if (err != EDSDK.EDS_ERR_OK)
    { LogError("カメラ・オブジェクトのリストのカウントを取得できません", true); return false; }
    if (count == 0)
    { LogError("接続されたカメラがありません [Camera List Count :0]", true); return false; }
    else
        Log.Info("Camera List Count :" + count.ToString(), 0);
    // カメラの取得
    for (int i = 0; i < count;i++ )
    {
        IntPtr ptr;
        err = EDSDK.EdsGetChildAtIndex(cameraList, i, out ptr);
        EDSDK.EdsDeviceInfo deviceInfo;
        err = EDSDK.EdsGetDeviceInfo(ptr, out deviceInfo);
        Log.Info(deviceInfo.szDeviceDescription, 0);
        if (deviceInfo.szDeviceDescription == "EOS 20D")
        {
            _eosCameraRef = ptr;
            break;
        }
    }
    if (_eosCameraRef == IntPtr.Zero)
    {
        LogError("EOS 20Dの接続点を取得できません。\r\nUSBケーブル再接続して下さい。", true);
        return false;
    }
    else
    {
        err = EDSDK.EdsOpenSession(_eosCameraRef);
        if (err != EDSDK.EDS_ERR_OK)
        {
            LogError("EOS 20Dとセッションを開始することができません。\r\nUSBケーブル再接続して下さい。", true);
            err = EDSDK.EdsRelease(_eosCameraRef);
            err = EDSDK.EdsTerminateSDK();
            return false;
        }
        InfoAppend("EOS 20D に接続しました。");
        //
        /* 
            * 現状はイベントを取得できないのでコメントアウトする
            * 
            */ 
        //_eosStateEventCallBack+=new EDSDK.EdsStateEventHandler(EosStateEventCallBack);
        //_eosPropertyEventCallBack+=new EDSDK.EdsPropertyEventHandler(EosPropertyEventCallBack);
        //_eosObjectEventCallBack += new EDSDK.EdsObjectEventHandler(EosObjectEventCallBack);
        //err = EDSDK.EdsSetObjectEventHandler(_eosCameraRef, EDSDK.ObjectEvent_All, _eosObjectEventCallBack, IntPtr.Zero);
        //if (err != EDSDK.EDS_ERR_OK)
        //    LogError("EOS イベントを登録できません");
        //else
        //    Log.Info("done eos object event", 0);  
                
    }
            
            
    return true;
}
3.2.2.カメラから切断
private void EosClose()
{
    if (_eosCameraRef == IntPtr.Zero)
        return;
    try
    {
        uint err = 0;
        err = EDSDK.EdsCloseSession(_eosCameraRef);
        err = EDSDK.EdsRelease(_eosCameraRef);
        //err = EDSDK.EdsTerminateSDK();
    }
    catch (Exception)
    { }
    finally
    {
        _eosCameraRef = IntPtr.Zero;
    }
}
private void EosDisconnect()
{
    try
    {
        uint err = 0;
        err = EDSDK.EdsCloseSession(_eosCameraRef);
        err = EDSDK.EdsRelease(_eosCameraRef);
        err = EDSDK.EdsTerminateSDK();
    }
    catch (Exception)
    { }
}

3.2.3.画像のダウンロード
private List ReloadEos(bool delete = false)
{
    lock (_thisLock)
    {
        List fileList = new List();
        IntPtr camera = _eosCameraRef;
        if (camera == IntPtr.Zero)
        {
            LogError("カメラと接続できません", true);
            return fileList;
        }
        uint err;
        int count;
        int index;
        EDSDK.EdsDirectoryItemInfo dirInfo;
        IntPtr volumeRef = IntPtr.Zero;
        IntPtr dirRef = IntPtr.Zero;
        IntPtr dirFilesRef = IntPtr.Zero;
        err = EDSDK.EdsGetChildCount(camera, out count);
        if (err != EDSDK.EDS_ERR_OK)
        { LogError(string.Format("カメラ・オブジェクトのカウントが取得できません。 failed: {0:X}", err), true); return fileList; }
        if (count == 0)
        { LogError("カメラ・オブジェクトの子アイテムがありません"); return fileList; }
        //ドライブの取得
        index = count - 1;
        err = EDSDK.EdsGetChildAtIndex(camera, index, out volumeRef);
        if (err != EDSDK.EDS_ERR_OK)
        { LogError(string.Format("カメラ・ボリュームのカウントを取得できません。 failed: {0:X}", err) ); return fileList; }
        if (count == 0)
        { LogError("カメラ・ボリュームの子アイテムがありません"); return fileList; }
        //DCIM フォルダ (\DCIM)
        index = -1;
        for (int i = 0; i < count; i++)
        {
            err = EDSDK.EdsGetChildAtIndex(volumeRef, i, out dirRef);
            if (err != EDSDK.EDS_ERR_OK)
            { LogError(string.Format("DCIM folder index failed: {0:X}", err)); return fileList; }
            err = EDSDK.EdsGetDirectoryItemInfo(dirRef, out dirInfo);
            if (err != EDSDK.EDS_ERR_OK)
            { LogError(String.Format("DCIM folder directory item Info failed: {0:X}", err)); return fileList; }
            if (dirInfo.szFileName == "DCIM")
            {
                index = i;
                break;
            }
        }
        if (index == -1)
        { LogError("DCIM フォルダを取得できません"); return fileList; }
        //CANONフォルダ (\xxxCANON)
        err = EDSDK.EdsGetChildCount(dirRef, out count);
        if (err != EDSDK.EDS_ERR_OK)
        { Log.Info(String.Format("CANON folder child failed: {0:X}", err)); return fileList; }
        if (count == 0)
        { LogError("xxxCANON フォルダを取得できません"); return fileList; }
        Log.Info(string.Format("DCIM folders item count: {0:X}", count),0);
        int imageCount = 0;
        for (int k = 0; k < count; k++)
        {
            int fileCount = 0;
            err = EDSDK.EdsGetChildAtIndex(dirRef, k, out dirFilesRef);
            if (err != EDSDK.EDS_ERR_OK)
            { LogError(string.Format("xxxCANON folder index failed: {0:X}", err)); return fileList; }
            err = EDSDK.EdsGetChildCount(dirFilesRef, out fileCount);
            if (err != EDSDK.EDS_ERR_OK)
            { LogError(string.Format("Files count failed: {0:X}", err)); return fileList; } 
            #if DEBUG
            err = EDSDK.EdsGetDirectoryItemInfo(dirFilesRef, out dirInfo);
            if(err!=0)
            Log.Info(dirInfo.szFileName + " count :" + fileCount.ToString(),0);
            #endif                    
            for (int i = fileCount - 1; i >= 0; i--)
            {
                IntPtr fileRef = IntPtr.Zero;
                EDSDK.EdsDirectoryItemInfo fileInfo;
                string fileName = string.Empty;
                err = EDSDK.EdsGetChildAtIndex(dirFilesRef, i, out fileRef);
                if (err != EDSDK.EDS_ERR_OK)
                { LogError(string.Format("file index failed: {0:X}", err)); return fileList; }
                err = EDSDK.EdsGetDirectoryItemInfo(fileRef, out fileInfo);
                if (err != EDSDK.EDS_ERR_OK)
                { LogError(string.Format("file Info failed: {0:X}", err)); return fileList; }
                fileName = Env.ImagePath + "\\" + fileInfo.szFileName;
                //file download
                IntPtr fileStreamRef = IntPtr.Zero;
                err = EDSDK.EdsCreateFileStream(fileName, EDSDK.EdsFileCreateDisposition.CreateAlways, EDSDK.EdsAccess.ReadWrite, out  fileStreamRef);
                if (err != EDSDK.EDS_ERR_OK)
                { LogError(string.Format("Create FileStream failed: {0:X}", err)); return fileList; }
                err = EDSDK.EdsDownload(fileRef, fileInfo.Size, fileStreamRef);
                if (err != EDSDK.EDS_ERR_OK)
                { Log.Error(String.Format("Download failed: {0:X}", err)); return fileList; }
                err = EDSDK.EdsDownloadComplete(fileRef);
                if (err != EDSDK.EDS_ERR_OK)
                { Log.Error(String.Format("DownloadComplete failed: {0:X}", err)); return fileList; }
                fileList.Add(fileName);
                if (delete)
                {
                    err = EDSDK.EdsDeleteDirectoryItem(fileRef);
                    if (err != EDSDK.EDS_ERR_OK)
                    { Log.Info(String.Format("Delete failed: {0:X}", err)); }
                }
                imageCount++;
                err = EDSDK.EdsRelease(fileRef);
                err = EDSDK.EdsRelease(fileStreamRef);
            }
            err = EDSDK.EdsRelease(dirFilesRef);
        }
        InfoAppend(string.Format("EOS 20D Image Count: {0:X}",imageCount));
 
        return fileList;
    }
}

3.4.fo-dicom

fo-dicom の使い方

参照設定で Dicom.dll を追加しておきます。
using System.Runtime.InteropServices;
using Dicom;
using Dicom.Imaging;
using Dicom.IO.Buffer;
using Dicom.Network;
3.4.1.DicomDataset の作成
データセットにタグと値を追加します

注意点
日付は yyyyMMdd 形式
時間は HHmmss.fff 形式
年齢は 000Y 形式
性別は M,F,O
氏名は英数を使用。スペースは'^'。漢字はビジネスルールにて使用不可にしています。
DicomDataset dd = new DicomDataset();
dd.Add(DicomTag.SOPClassUID, DicomUID
.SecondaryCaptureImageStorage);
dd.Add(DicomTag.SOPInstanceUID, sopInstanceUID);
dd.Add(DicomTag.StudyDate, dt.ToString("yyyyMMdd"));
dd.Add(DicomTag.StudyTime, dt.ToString("HHmmss.fff"
));
などなど。。。。。
3.4.2.PixelDataの作成
ピクセルデータを作成して追加します
DicomPixelData pixelData = DicomPixelData.Create(dd,true);
byte[] pixels = GetPixels(bitmap, out rows, out columns);
MemoryByteBuffer buffer = new MemoryByteBuffer(pixels);
pixelData.SamplesPerPixel = 3; //
pixelData.PlanarConfiguration = 0; // 面構成 RGB.RGB.RGB...様並び
pixelData.BitsAllocated = 8; //
pixelData.BitsStored = 8; //8bit color
pixelData.HighBit = 7; //
pixelData.PixelRepresentation = 0; //
pixelData.Height = (ushort)bitmap.Height;
pixelData.Width = (ushort)bitmap.Width;
pixelData.PhotometricInterpretation = PhotometricInterpretation.Rgb;
pixelData.AddFrame(buffer);

3.4.3.ビットマップからピクセルテータを取得
ビットマップからLockBitsを使用してピクセルデータを取得します。

private static byte[] GetPixels(Bitmap image, out int rows, out int columns)
{
    // Format32bppArgb → Format24bppRgb
    if (imagePixelFormat != PixelFormat.Format24bppRgb)
    {
        Bitmap old = image;
        using (old)
        {
            image = new Bitmap(old.Width, old.Height, PixelFormat.Format24bppRgb);
            using (System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(image))
            {
                g.DrawImage(old, 0, 0, old.Width, old.Height);
            }
        }
    }
    rows = image.Height;
    columns = image.Width;
    if (rows % 2 != 0 && columns % 2 != 0)
        --columns;
    BitmapData data = image.LockBits(new Rectangle(0, 0, columns, rows), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
    IntPtr bmpData = data.Scan0;
    try
    {
        int stride = columns * 3;
        int size = rows * stride;
        byte[] pixelData = new byte[size];
        for (int i = 0; i < rows; ++i)
            Marshal.Copy(new IntPtr(bmpData.ToInt64() + i * data.Stride), pixelData, i * stride, stride);
        Swap(pixelData); //swap BGR to RGB
        return pixelData;
    }
    finally
    {
        image.UnlockBits(data);
    }
}
//  Red Blueの入れ替え 
private static void SwapRedBlue(byte[] pixels)
{
    for (int i = 0; i < pixels.Length; i += 3)
    {
        byte temp = pixels[i];
        pixels[i] = pixels[i + 2];
        pixels[i + 2] = temp;
    }
}
3.4.4.保存
DicomFileのコンストラクタにDicomDatasetを入れて,Saveします。
DicomFile dicomfile = new DicomFile(dd);
//※ ChangeTransferSyntax は [using Dicom.Imaging.Codec;]が必要。デフォルトはExplicitVRLittleEndian のようなのでImplicitVRLittleEndian に変更します
df = df.ChangeTransferSyntax(DicomTransferSyntax.ImplicitVRLittleEndian);
dicomfile.Save("c:\\dicom.dcm");

3.4.5.送信
public static bool SendScu(string[] files)
{
    if (files.Length == 0)
        return false;
    string callingAET = "";
    string calledAET = "";
    string host = "";
    int port = 104;
    DicomClient client = new DicomClient();
    try
    {
        for (int i = 0; i < files.Length; i++)
        {
            client.AddRequest(new DicomCStoreRequest(files[i]));
        }
    }
    catch (Exception ex)
    {
        Log.Error("Dicom Client Add Request: "+ ex.Message);
        return false;
    }
    try
    {
        client.Send(host, port, false, callingAET, calledAET);
    }
    catch (Exception ex)
    {
        Log.Error("Dicom Send: "+ ex.Message);
        return false;
    }
    finally
    {
        client.Release();
    }
    return true;
}
3.4.6. 送信の確認
直接サーバに送る前にローカルにて送信の実験をします。
ここではDCMTKのstorescp.exe を使用します。

以下のバッチファイルを作成して実行します。
cd c:\usr\dcmtk\bin
cmd /k storescp.exe --verbose -d -od C:\usr\dcmtk\bin\data 104


ログを参照してうまく転送できているかを確認。
C:/usr/dcmtk/bin/data 以下にファイルができているはずです。


今後の予定
エコーのゲートウェイを作成しようとかと。。。。
エコーでは術者の所見が必要ですが、サーバー転送後に画像を見ながら書くと、間違ったところを計測していたなどとお恥ずかしい画像も無きにしもあらず。そこで、ゲートウェイに一旦転送し、画像を確認してからサーバに転送することにします。加えて、患者名、患者番号などを再確認し、計測機能なども付加できればと。