前言
前面的四个博客介绍了 Visual Studio 2010环境下使用Dcmtk库需要的设置以及在Windows10和Ubuntu环境下dcmqrscp模拟PACS的配置方法。本文将在前面的基础上记录C++实现DIMSE-C规定的五种操作:
- C-ECHO
- C-STORE
- C-FIND
- C-GET
- C-MOVE
的方法。其中C-GET操作在使用NAT模式的Ubuntu虚拟机(PACS)和主机Win10相互通信时不能直接使用(具体原因请参见前一节),而且C-MOVE操作可以完美代替C-GET的功能,实际在本文中并未实现。
参考资料
DcmSCU example program
DcmSCP Class Reference
DICOM医学图像处理:storescp.exe与storescu.exe源码剖析,学习C-STORE请求
DICOM C-GET vs C-MOVE
C++ 多线程
源代码
先给出所有代码:(只有这一个C++源文件)
/*
* Author: Qin Yimin
* Date: 2019-12-13
* Purpose: Test Useage of the DcmSCU class
*/
#include "dcmtk/config/osconfig.h" /* make sure OS specific configuration is included first */
#include "dcmtk/dcmnet/diutil.h"
#include "dcmtk/dcmnet/scu.h"
#include "dcmtk/dcmnet/scp.h"
#include "dcmtk/dcmnet/dstorscp.h"
#include <Windows.h> // 用于创建storeSCP线程接收数据
#define OFFIS_CONSOLE_APPLICATION "testscu"
static OFLogger echoscuLogger = OFLog::getLogger("dcmtk.apps." OFFIS_CONSOLE_APPLICATION);
static char rcsid[] = "$dcmtk: v" OFFIS_DCMTK_VERSION " " OFFIS_DCMTK_RELEASEDATE " $";
/* 与PACS建立连接需要设置的参数 */
// our application entity title used for calling the peer machine 本机使用的AET
#define APPLICATIONTITLE "ACME1"
// host name of the peer machine SCP的主机名
#define PEERHOSTNAME "Ubuntu-Qin"
// TCP/IP port to connect to the peer machine SCP开放的端口号,设置时请参考PACS配置文件
#define PEERPORT 11112
// application entity title of the peer machine SCP使用的AET,设置时请参考PACS配置文件
#define PEERAPPLICATIONTITLE "ACME_STORE"
// MOVE destination AE Title C-MOVE操作的目的AET
#define MOVEAPPLICATIONTITLE "ACME3"
// C-STORE 操作中想要发送的文件名
# define STOREFILENAME "E:\\DCMTK\\PACS\\SCU\\database\\LI_GANG.CT.ABDOMEN_HX_CH_ABD_C_(ADULT).0006.0001.2019.10.13.22.03.30.948121.1070307945.IMA"
// C-GET 操作中想要存放图像的目录
# define STORAGEDIR "E:\\DCMTK\\PACS\\SCU\\getDest"
// C-STORE 或 C-GET 中用来接收数据的端口号
#define STORESCPPORT 1235
// C-MOVE 操作中想要存放图像的目录
# define MOVEDESTDIR "E:\\DCMTK\\PACS\\SCU\\moveDest"
/* 与PACS建立连接需要设置的参数 */
// 返回给定scu对象的描述上下文(Presentation Context)ID
DWORD WINAPI storeScpThread(LPVOID lpParameter){
DcmStorageSCP* pSCP = (DcmStorageSCP*)lpParameter;
pSCP->listen(); // 开启storeSCP监听C-MOVE请求
return 0L;
}
static Uint8 findUncompressedPC(const OFString& sopClass,
DcmSCU& scu)
{
Uint8 pc;
pc = scu.findPresentationContextID(sopClass, UID_LittleEndianExplicitTransferSyntax);
if (pc == 0)
pc = scu.findPresentationContextID(sopClass, UID_BigEndianExplicitTransferSyntax);
if (pc == 0)
pc = scu.findPresentationContextID(sopClass, UID_LittleEndianImplicitTransferSyntax);
return pc;
}
int main(int argc, char *argv[])
{
/* 设置建立连接需要的参数 */
/* Setup DICOM connection parameters */
OFLog::configure(OFLogger::DEBUG_LOG_LEVEL); // 使用DEBUG模式的日志,输出连接建立的详细信息
DcmSCU scu; // 创建一个DcmSCU对象
// set AE titles
scu.setAETitle(APPLICATIONTITLE); // 本机使用的AET,需要根据PACS配置文件设置
scu.setPeerHostName(PEERHOSTNAME); // SCP的主机名,如果是本机设置为 localhost
scu.setPeerPort(PEERPORT); // SCP的端口号
scu.setPeerAETitle(PEERAPPLICATIONTITLE); // SCP的AET,需要根据PACS配置文件设置
/* 设置建立连接需要的参数 */
// Use presentation context for FIND/MOVE in study root, propose all uncompressed transfer syntaxes
// 提供供SCP选择的传输语义(Transfer syntax)列表
OFList<OFString> ts;
ts.push_back(UID_LittleEndianExplicitTransferSyntax);
ts.push_back(UID_BigEndianExplicitTransferSyntax);
ts.push_back(UID_LittleEndianImplicitTransferSyntax);
// 根据抽象语义(abstract syntax)和传输语义列表构建描述上下文
scu.addPresentationContext(UID_VerificationSOPClass, ts); // C-ECHO操作,abstract syntax为"VerificationSOPClass"
scu.addPresentationContext(UID_CTImageStorage,ts); // C-STORE操作,本程序的目的是存储CT图像
scu.addPresentationContext(UID_FINDStudyRootQueryRetrieveInformationModel, ts); // C-FIND操作,abstract syntax为"使用基于研究(Study)的查询/检索模型"
scu.addPresentationContext(UID_MOVEStudyRootQueryRetrieveInformationModel, ts); // C-MOVE操作,abstract syntax为"使用基于研究(Study)的查询/检索模型"
// scu.addPresentationContext(UID_GETStudyRootQueryRetrieveInformationModel, ts); // C-GET操作,abstract syntax为"使用基于研究(Study)的查询/检索模型"
/* 与PACS系统建立连接 */
/* Initialize network */
OFCondition result = scu.initNetwork();
if (result.bad()) // 网络初始化失败
{
DCMNET_ERROR("Unable to set up the network: " << result.text());
return 1;
}
/* Negotiate Association */
result = scu.negotiateAssociation();
if (result.bad()) // 无法建立连接
{
DCMNET_ERROR("Unable to negotiate association: " << result.text());
return 1;
}
/* 与PACS系统建立连接 */
/* Let's look whether the server is listening:
Assemble and send C-ECHO request
*/
result = scu.sendECHORequest(0);
if (result.bad()) // C-ECHO操作失败,应检查SCP的hostname和port设置
{
DCMNET_ERROR("Could not process C-ECHO with the server: " << result.text());
return 1;
}
/* C-STORE 操作 */
/* Assemble and send C-STORE request */
T_ASC_PresentationContextID presID = findUncompressedPC(UID_CTImageStorage, scu);
if (presID == 0)
{
DCMNET_ERROR("There is no uncompressed presentation context for C-STORE");
return 1;
}
Uint16 rspStatusCode;
result = scu.sendSTORERequest(presID,STOREFILENAME,NULL,rspStatusCode);
if (result.bad()){
DCMNET_ERROR("C-STORE Operation failed.");
}
else{
DCMNET_INFO("C-STORE Operation completed successfully.");
}
/* C-Find 操作 */
/* Assemble and send C-FIND request */
OFList<QRResponse*> findResponses;
DcmDataset req;
req.putAndInsertOFStringArray(DCM_StudyID,"");
req.putAndInsertOFStringArray(DCM_StudyDate,"");
req.putAndInsertOFStringArray(DCM_QueryRetrieveLevel, "STUDY");
req.putAndInsertOFStringArray(DCM_StudyInstanceUID, "");
presID = findUncompressedPC(UID_FINDStudyRootQueryRetrieveInformationModel, scu);
if (presID == 0)
{
DCMNET_ERROR("There is no uncompressed presentation context for Study Root FIND");
return 1;
}
result = scu.sendFINDRequest(presID, &req, &findResponses);
if (result.bad()) {
DCMNET_ERROR("Unable to send find request successfully.");
return 1;
}
else {
// 最后一个findResponse通常只标志find请求成功并且数据信息已全部发送
DCMNET_INFO("There are " << findResponses.size()-1 << " studies available");
}
/* C-Find 操作 */
/** C-MOVE 操作 */
/* Assemble and send C-MOVE request, for each study identified above*/
presID = findUncompressedPC(UID_MOVEStudyRootQueryRetrieveInformationModel, scu);
if (presID == 0)
{
DCMNET_ERROR("There is no uncompressed presentation context for Study Root MOVE");
return 1;
}
OFListIterator(QRResponse*) study = findResponses.begin();
Uint32 studyCount = 1;
OFBool failed = OFFalse;
/* 设置SCP来接收数据 */
DcmStorageSCP scp; // 新建一个DcmStorageSCP对象
scp.setAETitle(MOVEAPPLICATIONTITLE); // 设置AET为C-MOVE操作指定的AET
scp.setPort(STORESCPPORT); // 设置storeSCP监听的端口号,需要与PACS的配置文件中AET保持一致
scp.setVerbosePCMode(OFFalse); // 设置Verbose模式便于调试
scp.addPresentationContext(UID_CTImageStorage, ts); // 设置接收PC的abstract syntax和transfer syntax
scp.setOutputDirectory(MOVEDESTDIR);
HANDLE storeScpThreadHandle = CreateThread(NULL, 0, storeScpThread, &scp, 0, NULL);
// Every while loop run will get all image for a specific study
while (study != findResponses.end() && result.good())
{
// be sure we are not in the last response which does not have a dataset
if ( (*study)->m_dataset != NULL)
{
OFString studyInstanceUID;
result = (*study)->m_dataset->findAndGetOFStringArray(DCM_StudyInstanceUID, studyInstanceUID);
// only try to get study if we actually have study instance uid, otherwise skip it
if (result.good())
{
req.putAndInsertOFStringArray(DCM_StudyInstanceUID, studyInstanceUID);
// fetches all images of this particular study
result = scu.sendMOVERequest(presID, MOVEAPPLICATIONTITLE, &req, NULL /* we are not interested into responses*/);
if (result.good())
{
DCMNET_INFO("Received study #" << std::setw(7) << studyCount << ": " << studyInstanceUID);
studyCount++;
}
/*result = scp.receiveMOVERequest(NULL, 0, NULL, MOVEDESTDIR);
if ( result.bad() ){
DCMNET_ERROR("Receiving move request failed.");
}*/
}
}
study++;
}
//关闭线程
CloseHandle(storeScpThreadHandle);
if (result.bad())
{
DCMNET_ERROR("Unable to retrieve all studies: " << result.text());
}
while (!findResponses.empty())
{
delete findResponses.front();
findResponses.pop_front();
}
/* C-MOVE 操作 */
/* Release association */
scu.closeAssociation(DCMSCU_RELEASE_ASSOCIATION);
return 0;
}
C-MOVE
参考资料1中实际上已经给出了C-ECHO、C-FIND和C-MOVE的实现,但是C-MOVE操作实际上并不能直接运行。。。C-STORE的实现可以参考这三个例子,由于我们只考虑CT图像的存储,实现起来也比较简单。所以着重记录一下C-MOVE的实现过程。在这之前先回顾一下C-MOVE的问题出在哪里 DCMTK开发笔记(四):C-MOVE操作详解 。
官方给出的代码如下:
/* Assemble and send C-MOVE request, for each study identified above*/
presID = findUncompressedPC(UID_MOVEStudyRootQueryRetrieveInformationModel, scu);
if (presID == 0)
{
DCMNET_ERROR("There is no uncompressed presentation context for Study Root MOVE");
return 1;
}
OFListIterator(QRResponse*) study = findResponses.begin();
Uint32 studyCount = 1;
OFBool failed = OFFalse;
// Every while loop run will get all image for a specific study
while (study != findResponses.end() && result.good())
{
// be sure we are not in the last response which does not have a dataset
if ( (*study)->m_dataset != NULL)
{
OFString studyInstanceUID;
result = (*study)->m_dataset->findAndGetOFStringArray(DCM_StudyInstanceUID, studyInstanceUID);
// only try to get study if we actually have study instance uid, otherwise skip it
if (result.good())
{
req.putAndInsertOFStringArray(DCM_StudyInstanceUID, studyInstanceUID);
// fetches all images of this particular study
result = scu.sendMOVERequest(presID, MOVEAPPLICATIONTITLE, &req, NULL /* we are not interested into responses*/);
if (result.good())
{
DCMNET_INFO("Received study #" << std::setw(7) << studyCount << ": " << studyInstanceUID);
studyCount++;
}
}
}
study++;
}
if (result.bad())
{
DCMNET_ERROR("Unable to retrieve all studies: " << result.text());
}
while (!findResponses.empty())
{
delete findResponses.front();
findResponses.pop_front();
}
这个例子的问题在于,并没有指定C-MOVE操作的目的地,因为C-MOVE操作也可以让PACS发送数据给另一台主机。在我们的应用中实际是要实现C-GET的功能,即发送C-MOVE的请求者同时也是数据接收者。为此,我们需要在客户端开启一个SCP来接收PACS发送的数据。SCP设置如下:
/* 设置SCP来接收数据 */
DcmStorageSCP scp; // 新建一个DcmStorageSCP对象
scp.setAETitle(MOVEAPPLICATIONTITLE); // 设置AET为C-MOVE操作指定的AET
scp.setPort(STORESCPPORT); // 设置storeSCP监听的端口号,需要与PACS的配置文件中AET保持一致
scp.setVerbosePCMode(OFFalse); // 设置Verbose模式便于调试
scp.addPresentationContext(UID_CTImageStorage, ts); // 设置接收PC的abstract syntax和transfer syntax
scp.setOutputDirectory(MOVEDESTDIR);
开启SCP需要调用scp.listen()方法,但是实践发现这一语句摆在哪里代码都不能正确运行。原因是循环等待造成的,我们发送的C-MOVE请求不止一个,SCP需要保持监听,如果scp.listen()在前,程序进入等待无法发送请求;如果在后,发送请求时PACS无法和SCP建立连接,又会出错。所以正解是采用多线程的方式,模仿参考资料5,可以写出进程函数:
DWORD WINAPI storeScpThread(LPVOID lpParameter){
DcmStorageSCP* pSCP = (DcmStorageSCP*)lpParameter;
pSCP->listen(); // 开启storeSCP监听C-MOVE请求
return 0L;
}
然后在发送C-MOVE请求的while循环体前后分别启动和关闭进程:
HANDLE storeScpThreadHandle = CreateThread(NULL, 0, storeScpThread, &scp, 0, NULL);
/* while...sendMoveRequest*/
CloseHandle(storeScpThreadHandle);
问题得解。
C-GET
下面尝试在PACS未部署在虚拟机的情况下实现C-GET操作。首先使用命令行工具模拟C-GET的过程:
getscu localhost 11112 -v -S -aec ACME_STORE -aet ACME1 -k QueryRetrieveLevel=STUDY -k StudyDate -k StudyDescription -k StudyInstanceUID -od E:\DCMTK\PACS\SCU\getDest
通信日志如下:
I: Requesting Association
I: Association Accepted (Max Send PDV: 16372)
I: Sending C-GET Request (MsgID 1)
I: Received C-STORE Request (MsgID 1)
W: DICOM file already exists, overwriting: E:\DCMTK\PACS\SCU\getDest\CT.1.3.12.2.1107.5.1.4.73473.30000013061923223125000058319
I: Sending C-STORE Response (Success)
I: Received C-GET Response (Pending)
I: Received C-STORE Request (MsgID 2)
W: DICOM file already exists, overwriting: E:\DCMTK\PACS\SCU\getDest\CT.2.16.840.1.113662.2.1.4519.41582.4105152.419990505.410523251
I: Sending C-STORE Response (Success)
I: Received C-GET Response (Pending)
I: Received C-GET Response (Success)
I: Final status report from last C-GET message:
I: Number of Remaining Suboperations : 0
I: Number of Completed Suboperations : 2
I: Number of Failed Suboperations : 0
I: Number of Warning Suboperations : 0
I: Releasing Association
可以看到在同一个连接中,双方相互交换C-GET和C-STORE信息,这时客户端不仅仅是C-GET操作的SCU,同时也会C-STORE的SCP。情况比我们想象的要复杂。。。
how can I use dcmtk for C-Get?
DICOM:C-GET与C-MOVE对比剖析
PACS connection 这是一个没什么卵用但很好玩的帖子hh
开发人员给出了较详细的解释:
For C-GET to work, the SCU needs to negotiate one of the C-GET SOP classes with SCU role and, in the same association, negotiate some of the C-STORE (Storage) SOP classes with SCP role. This requires the optional SCP/SCU role negotiation sub-item to be used in the A-ASSOCIATE protocol, something which the SCU developer possibly “forgot” to implement. The complexity of the SCP/SCU role negotiation is also the main reason why almost nobody is using C-GET in practice.
所以,看起来C-GET好像不是特别有必要了hh。