文章目录
- 开发环境
- 创建 Qt Widgets 程序
- 设计界面
- 配置 LeadTools 路径
- 编写代码
- 使用 LDicomNet 实现 SCP 的步骤
- 日志输出
- 编写 SCP Server 类
- 编写 SCP Client 类
- 启动 LDicomNet 及启动监听
- 编译程序
- 运行程序
- 发布与部署
- 测试程序
- 界面美化
- 参考
开发环境
- LeadTools 17
- Qt 5.15.2 MSVC2019 32bit
- Qt Creator 7.0.0
- Visual Studio 2019
- Windows 11
创建 Qt Widgets 程序
启动 Qt Creator,点击欢迎界面左上角的 Create Project… 按钮,打开 “New Project” 窗口,选择 Qt Widgets Application
。
点击 Choose… 按钮,进入 Project Location 界面,输入项目名称,点击 下一步(N)> 按钮,进入下一个界面。
继续点击 下一步(N)> 按钮。
注:Qt Creator 默认文件名全小写,如需单词首字母大写文件名,可通过菜单【工具 > 选项】打开选项窗口,再选择【C++ > File Naming】,去掉 Lower case file names
前的勾。
另外,Qt Creator 默认使用 #ifndef
避免同一个文件被多次包含,如果想使用 #pragma once
,也可以在这个界面设置,只要勾选 Use "#pragma once" instead of "#ifndef" guards
即可。
继续点击 下一步(N)> 按钮。
说明:由于本文使用的 LeadTools 版本较老,此处一定要选择 MSVC 32 位编译器,不知新版 LeadTools 对 64 位及 MinGW 支持得怎么样。
继续点击 下一步(N)>,最后点击 完成(F) 按钮。创建完成的项目如下图:
设计界面
双击 MainWindow.ui
打开设计界面。
向窗体上拖拽一个 Horizontal Layout
控件和一个 Table View
控件。
再依次向 Horizontal Layout
里拖拽 Combo Box
、Line Edit
、Push Button
、Line Edit
、Push Button
五个控件,从左到右顺序排列。然后,依次设置这 5 个控件的 objectName
为:cmbAddress
、edtPort
、btnListen
、edtImagePath
、btnBrowse
。
- 设置
edtPort
的maximumSize > 宽度
为60
,text
为6666
,maxLength
为5
。 - 设置
btnListen
的text
为启动监听
。 - 设置
edtImagePath
的readOnly
为true
。 - 设置
btnBrowse
的text
为浏览...
。
在 MainWindow
空白处点击鼠标右键,在弹出菜单上选择 布局 > 垂直布局
。
设计完成的界面如下:
界面结构图:
配置 LeadTools 路径
在项目结构图里,在项目名称 CStoreSCPDemo
上点击鼠标右键,在弹出菜单上选择 添加库...
,打开 添加库 对话框。
在 “添加库” 对话框中选择 外部库
,然后点击 下一步(N)> 按钮。
在 Details 界面,选择 库文件
和 包含路径
,取消勾选 Linux
和 Mac
,取消勾选 为debug版本添加'd'作为后缀
。
继续点击 下一步(N)>,然后点击 完成(F) 按钮。
点击 完成(F) 后,CStoreSCPDemo.pro
文件将自动打开,在 win32: LIBS
一行的最后,再添加 -lLtkrn_u -lLtwvc_u
,如下:
win32: LIBS += -L'E:/LeadTools 17/Lib/CDLL/Win32/' -lLtdic_u -lLtkrn_u -lLtwvc_u
编写代码
编码前,应该对 DICOM、SCU、SCP、DIMSE、C-STORE、AE 等概念有简单了解,不了解的小伙伴可以先阅读这篇文章:DICOM医学图像处理:DICOM网络传输
LeadTools 提供了 LDicomNet 类用以支持 DICOM 网络连接和消息传输。LDicomNet 类支持 DICOM 协议的全部 11 种 DIMSE 服务(C-STORE、C-GET、C-MOVE、C-FIND、C-ECHO、N-EVENT-REPORT、N-GET、N-SET、N-ACTION、N-CREATE、N-DELETE)。
LDicomNet 类同时实现了对 SCU 和 SCP 的支持,我们在实际使用时,只需重写特定的虚函数,即可实现不同的 SCU 或 SCP 服务。本文我们仅实现 C-STORE SCP。
使用 LDicomNet 实现 SCP 的步骤
- 以 LDicomNet 为基类,定义两个派生类,一个用于接收 SCU Connection,一个用于处理 DIMSE 消息。接收 SCU Connection 的类我们称为 SCP Server,处理 DIMSE 消息的类我们称为 SCP Client。
- 重写 SCP Server 的
OnAccept()
函数。当接收到 SCU Connection 时,OnAccept()
函数将会被自动调用。在OnAccept()
内,以 SCP Client 实例为参数调用Accept()
函数,接受 SCU Connection 请求。 - 重写 SCP Client 的
OnReceiveAssociateRequest()
函数。当接收到 SCU Association 时,OnReceiveAssociateRequest()
函数将会被自动调用。建立 Association 连接是两个 DICOM 实体(AE)之间进行交互的第一步。成功建立 Association 连接后,才能进行 DIMSE 消息交换。 - 根据所要实现的 DIMSE 服务不同,重写 SCP Client 类的特定函数。如实现 C-STORE SCP 服务,需重写
OnReceiveCStoreRequest()
函数。
日志输出
在前面的界面设计中,在界面上放了一个 Table View
控件,这是用来显示操作日志的。
日志显示功能,准备使用 Qt 的自定义事件实现。为此,需要定义一个派生自 QEvent
的日志事件类,还需要重写 MainWindow
的 customEvent
函数,最后再把显示日志的功能封装成一个 showLog
函数。
这里之所以要先提一下显示日志的实现方式,是因为在编写 SCP Server 和 SCP Client 时需要考虑如何输出日志。
// LogEvent.h
#pragma once
#include <QEvent>
#include <QString>
const QEvent::Type LogEventType = QEvent::Type(QEvent::User+1);
class LogEvent : public QEvent
{
public:
LogEvent(QString text);
QString text() {
return m_text;
}
private:
QString m_text;
};
// LogEvent.cpp
#include "LogEvent.h"
LogEvent::LogEvent(QString text)
: QEvent(LogEventType)
{
m_text = text;
}
void MainWindow::customEvent(QEvent* event)
{
if (event->type() != LogEventType) {
return;
}
LogEvent* logEvent = static_cast<LogEvent*>(event);
showLog(logEvent->text());
}
void MainWindow::showLog(QString strLog)
{
int rowCount = m_tableModel.rowCount();
m_tableModel.setItem(rowCount, 0, new QStandardItem(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss:zzz")));
m_tableModel.setItem(rowCount, 1, new QStandardItem(strLog));
QCoreApplication::processEvents();
}
编写 SCP Server 类
SCP Server 类派生自 LDicomNet
,用于接收 SCU Connection 请求,代码相对简单,核心代码在 OnAccept
函数。
SCPServer
类头文件定义如下:
// SCPServer.h
#pragma once
#include <QString>
#include <QObject>
#define LTV17_CONFIG
#include "Ltdic.h"
#include "SCPClient.h"
class SCPServer : public LDicomNet
{
public:
SCPServer();
void setServerIPPort(QString strIP, uint nPort) {
m_strIP = strIP;
m_nPort = nPort;
}
void setImageFolder(QString strImageFolder) {
m_strImageFolder = strImageFolder;
}
void registerLogger(QObject* pLogger) {
m_pLogger = pLogger;
}
void Close();
private:
QString m_strIP;
uint m_nPort;
QString m_strImageFolder;
QObject* m_pLogger = nullptr;
L_VOID OnAccept(L_INT nError);
L_VOID OnClose(L_INT nError, LDicomNet *pClient);
void outputLog(QString strLog);
void manageConnection(QString strClientIP, SCPClient *pClient);
};
OnAccept
函数代码如下:
L_VOID SCPServer::OnAccept(L_INT nError)
{
if (nError != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nError);
QString strError = QString("接收 SCU 连接时发生错误,原因:%1").arg(dcmError);
outputLog(strError);
return;
}
SCPClient* pClient = new SCPClient();
pClient->setImageFolder(m_strImageFolder);
pClient->registerLogger(m_pLogger);
L_INT nRet = Accept(pClient);
if(nRet != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nRet);
QString strError = QString("接收 SCU 连接时发生错误,原因:%1").arg(dcmError);
outputLog(strError);
pClient->Close();
delete pClient;
return;
}
L_TCHAR szPeerAddress[PDU_MAX_TITLE_SIZE];
L_UINT nPeerPort;
pClient->GetPeerInfo(szPeerAddress, sizeof(L_TCHAR) * PDU_MAX_TITLE_SIZE, &nPeerPort);
QString strPeerIP = QString::fromStdWString(szPeerAddress);
pClient->setSCUIPPort(strPeerIP, nPeerPort);
pClient->setServerIPPort(m_strIP, m_nPort);
QString strLog = QString("%1:接受一个 SCU 连接(%1:%2)").arg(strPeerIP).arg(nPeerPort);
outputLog(strLog);
manageConnection(strPeerIP, pClient);
}
编写 SCP Client 类
SCP Client 类派生自 LDicomNet
,用于接收 SCU Association 请求,以及实现 C-STORE SCP 服务,核心代码在 OnReceiveAssociateRequest
和 OnReceiveCStoreRequest
函数。
SCPClient
类头文件定义如下:
// SCPClient.h
#pragma once
#include <QString>
#include <QObject>
#include <QCoreApplication>
#define LTV17_CONFIG
#include "Ltdic.h"
class SCPClient : public LDicomNet
{
public:
SCPClient();
void setServerIPPort(QString strServerIP, uint nServerPort) {
m_strServerIP = strServerIP;
m_nServerPort = nServerPort;
}
QString getSCUIP() {
return m_strSCUIP;
}
void setSCUIPPort(QString strSCUIP, uint nSCUPort) {
m_strSCUIP = strSCUIP;
m_nSCUPort = nSCUPort;
}
QString getSCUAETitle() {
return m_strSCUAETitle;
}
void setImageFolder(QString strImageFolder) {
m_strImageFolder = strImageFolder;
}
void registerLogger(QObject* pLogger) {
m_pLogger = pLogger;
}
private:
QString m_strServerIP;
uint m_nServerPort = 0;
QString m_strSCUIP;
uint m_nSCUPort = 0;
QString m_strSCUAETitle;
QString m_strImageFolder = QCoreApplication::applicationDirPath();
QObject* m_pLogger = nullptr;
L_VOID OnReceiveAssociateRequest (LDicomAssociate *pPDU);
L_VOID OnReceiveCStoreRequest(L_UCHAR nPresentationID, L_UINT16 nMessageID, L_TCHAR* pszClass, L_TCHAR* pszInstance, L_UINT16 nPriority, L_TCHAR* pszMoveAE, L_UINT16 nMoveMessageID, LDicomDS* pDS);
L_VOID OnReceiveReleaseRequest();
L_VOID OnReceiveAbort(L_UCHAR nSource, L_UCHAR nReason);
bool getElementValue(LDicomDS* pDS, L_UINT32 nTag, QString& strValue);
bool getElementValue(LDicomDS* pDS, pDICOMELEMENT pElement, QString& strValue);
bool getDateTime(LDicomDS* pDS, L_UINT32 nDateTag, L_UINT32 nTimeTag, QDateTime& dateTime);
void outputLog(QString strLog);
};
OnReceiveAssociateRequest
函数代码如下:
L_VOID SCPClient::OnReceiveAssociateRequest(LDicomAssociate *pPDU)
{
outputLog(m_strSCUIP + ":收到 SCU Associate 请求...");
//要先调用 GetCalled 后调用 GetCalling,否则出错
L_TCHAR szCalled[PDU_MAX_TITLE_SIZE];
pPDU->GetCalled(szCalled, PDU_MAX_TITLE_SIZE);
L_TCHAR szCalling[PDU_MAX_TITLE_SIZE];
pPDU->GetCalling(szCalling, PDU_MAX_TITLE_SIZE);
QString strClientAE = QString::fromWCharArray(szCalling);
m_strSCUAETitle = strClientAE;
//验证客户端版本
L_UINT16 nVer=pPDU->GetVersion();
if(nVer != 1) {
outputLog(QString("%1:不支持的协议版本(%2),拒绝该连接请求。").arg(m_strSCUIP).arg(nVer));
SendAssociateReject(PDU_REJECT_RESULT_PERMANENT, PDU_REJECT_SOURCE_PROVIDER1, PDU_REJECT_REASON_VERSION);
return;
}
LDicomAssociate dicomAssociate(L_FALSE);
dicomAssociate.Reset(L_FALSE);
dicomAssociate.SetCalled(szCalled);
dicomAssociate.SetCalling(szCalling);
dicomAssociate.SetApplication(const_cast<L_TCHAR*>(UID_APPLICATION_CONTEXT_NAME));
dicomAssociate.SetVersion(1);
L_TCHAR szUID[] = L"1.2.3.108.56497.33";
L_TCHAR szVersion[] = L"StoreSCP";
dicomAssociate.SetImplementClass(L_TRUE, szUID);
dicomAssociate.SetImplementVersion(L_TRUE, szVersion);
QStringList firstTransferList;
firstTransferList.append(QString::fromWCharArray(UID_JPEG_LOSSLESS_NONHIER_14B));
firstTransferList.append(QString::fromWCharArray(UID_JPEG_LOSSLESS_NONHIER_14));
firstTransferList.append(QString::fromWCharArray(UID_JPEG_LOSSLESS_NONHIER_15));
firstTransferList.append(QString::fromWCharArray(UID_JPEG_LOSSLESS_HIER_PROCESS_28));
firstTransferList.append(QString::fromWCharArray(UID_JPEG_LOSSLESS_HIER_PROCESS_29));
firstTransferList.append(QString::fromWCharArray(UID_JPEG2000_LOSSLESS_ONLY));
firstTransferList.append(QString::fromWCharArray(UID_RLE_LOSSLESS));
firstTransferList.append(QString::fromWCharArray(UID_EXPLICIT_VR_LITTLE_ENDIAN));
firstTransferList.append(QString::fromWCharArray(UID_IMPLICIT_VR_LITTLE_ENDIAN));
firstTransferList.append(QString::fromWCharArray(UID_EXPLICIT_VR_BIG_ENDIAN));
QList<L_TCHAR*> szFirstTransferList;
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_NONHIER_14B));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_NONHIER_14));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_NONHIER_15));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_HIER_PROCESS_28));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_HIER_PROCESS_29));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_JPEG2000_LOSSLESS_ONLY));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_RLE_LOSSLESS));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_EXPLICIT_VR_LITTLE_ENDIAN));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_IMPLICIT_VR_LITTLE_ENDIAN));
szFirstTransferList.append(const_cast<L_TCHAR*>(UID_EXPLICIT_VR_BIG_ENDIAN));
//在 Abstract 表中查找,如果存在(并能找到Transfer),表示支持。优先支持无损压缩的传输语法。
QStringList acceptClassUIDs;
L_TCHAR szAbstract[PDU_MAX_UID_SIZE];
L_INT nPresentationCount = pPDU->GetPresentationCount();
for (int ind = 0; ind < nPresentationCount; ind++) {
L_UCHAR nID = pPDU->GetPresentation(ind);
pPDU->GetAbstract(nID, szAbstract, PDU_MAX_UID_SIZE);
QString strAbstract = QString::fromWCharArray(szAbstract);
if (acceptClassUIDs.contains(strAbstract)) {
dicomAssociate.AddPresentation(nID, PDU_ACCEPT_RESULT_ABSTRACT_SYNTAX, szAbstract);
continue;
} else {
acceptClassUIDs.append(strAbstract);
}
dicomAssociate.AddPresentation(nID, PDU_ACCEPT_RESULT_SUCCESS, szAbstract);
QStringList transferList;
L_TCHAR szTransfer[PDU_MAX_UID_SIZE];
L_INT transferCount = pPDU->GetTransferCount(nID);
for (int j = 0; j < transferCount; j++) {
pPDU->GetTransfer(nID, j, szTransfer, PDU_MAX_UID_SIZE);
QString strTransfer = QString::fromWCharArray(szTransfer);
if (transferList.contains(strTransfer) == false) {
transferList.append(strTransfer);
}
}
bool bFound = false;
for (int j = 0; j < firstTransferList.length(); j++) {
if (transferList.contains(firstTransferList.at(j))) {
dicomAssociate.AddTransfer(nID, szFirstTransferList[j]);
bFound = true;
break;
}
}
if (false == bFound && transferList.length() > 0) {
L_TCHAR pszTransfer[PDU_MAX_TITLE_SIZE];
transferList.at(0).toWCharArray(pszTransfer);
pszTransfer[transferList.at(0).length()] = '\0';
dicomAssociate.AddTransfer(nID, pszTransfer);
bFound = true;
}
if (bFound) {
dicomAssociate.SetResult(nID, PDU_ACCEPT_RESULT_SUCCESS);
} else {
dicomAssociate.SetResult(nID, PDU_ACCEPT_RESULT_TRANSFER_SYNTAX);
}
}
L_INT nRet = SendAssociateAccept(&dicomAssociate);
if (nRet != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nRet);
QString strError = QString("%1:发送 associate accept 发生错误,原因:%2").arg(m_strSCUIP, dcmError);
outputLog(strError);
return;
}
outputLog(m_strSCUIP + ":SCU Associate 已接受。");
}
OnReceiveCStoreRequest
函数代码如下:
L_VOID SCPClient::OnReceiveCStoreRequest(L_UCHAR nPresentationID, L_UINT16 nMessageID, L_TCHAR* pszClass, L_TCHAR* pszInstance, L_UINT16 nPriority, L_TCHAR* pszMoveAE, L_UINT16 nMoveMessageID, LDicomDS* pDS)
{
Q_UNUSED(nPriority)
Q_UNUSED(pszMoveAE)
Q_UNUSED(nMoveMessageID)
if (nullptr == pDS) {
outputLog(m_strSCUIP + ":C-STORE DataSet Is NULL,返回 COMMAND_STATUS_PROCESSING_FAILURE。");
SendCStoreResponse(nPresentationID, nMessageID, pszClass, pszInstance, COMMAND_STATUS_PROCESSING_FAILURE);
return;
}
QString strInstanceUID = QString::fromWCharArray(pszInstance);
QString strPatientID, strPatientName;
getElementValue(pDS, TAG_PATIENT_ID, strPatientID);
getElementValue(pDS, TAG_PATIENT_NAME, strPatientName);
outputLog(QString("%1:收到 C-STORE 请求,PatientName:%2,InstanceUID:%3,ClassUID:%4").arg(m_strSCUIP, strPatientName, strInstanceUID, pszClass));
if (strPatientName.isEmpty() || "***" == strPatientName) { //此处对***做特殊处理,是因为测试图像的PatientName是***
strPatientName = "UnknownName";
}
QString strStudyDate;
QDateTime dtStudyTime;
bool bSuccess = getDateTime(pDS, TAG_STUDY_DATE, TAG_STUDY_TIME, dtStudyTime);
if (bSuccess) {
strStudyDate = dtStudyTime.toString("yyyyMMdd");
} else {
strStudyDate = QDateTime::currentDateTime().toString("yyyyMMdd");
}
QString strModality;
getElementValue(pDS, TAG_MODALITY, strModality);
QString strSopInstanceUID = QString::fromWCharArray(pszInstance);
QString strImagePath = QString("%1/%2").arg(m_strImageFolder, strPatientName);
QDir qDir;
if (qDir.exists(strImagePath) == false) {
qDir.mkpath(strImagePath);
}
QString strImageFilename = QString("%1/%2_%3.dcm").arg(strImagePath, strModality, strSopInstanceUID);
L_TCHAR pszImageFilename[_MAX_PATH];
strImageFilename.toWCharArray(pszImageFilename);
pszImageFilename[strImageFilename.length()] = '\0';
try {
pDS->ChangeTransferSyntax(const_cast<L_TCHAR*>(UID_JPEG_LOSSLESS_NONHIER_14B), 100, DICOM_CHANGETRAN_MINIMIZE_JPEG_SIZE);
int nRet = pDS->SaveDS(pszImageFilename, DS_METAHEADER_PRESENT);
if(nRet != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nRet);
QString strError = QString("%1:保存 DICOM 文件到 %2 失败,原因:%3").arg(m_strSCUIP, strImageFilename, dcmError);
outputLog(strError);
SendCStoreResponse(nPresentationID, nMessageID, pszClass, pszInstance, COMMAND_STATUS_PROCESSING_FAILURE);
return;
}
QString strLog = m_strSCUIP + ":保存 DICOM 文件到 " + strImageFilename;
outputLog(strLog);
} catch(...) {
outputLog(QString("%1:保存 DICOM 文件到 %2 失败,返回 COMMAND_STATUS_PROCESSING_FAILURE 状态。").arg(m_strSCUIP, strImageFilename));
SendCStoreResponse(nPresentationID, nMessageID, pszClass, pszInstance, COMMAND_STATUS_PROCESSING_FAILURE);
return;
}
outputLog(m_strSCUIP + ":保存 DICOM 文件成功,返回 COMMAND_STATUS_SUCCESS 状态。");
SendCStoreResponse(nPresentationID, nMessageID, pszClass, pszInstance, COMMAND_STATUS_SUCCESS);
}
启动 LDicomNet 及启动监听
在 MainWindow
的构造函数里调用 LDicomNet::StartUp()
来启动 LDicomNet。
启动监听是通过调用 SCPServer
实例的 Listen()
函数完成的。整个启动监听的过程,封装成了一个名为 startListen()
的函数,便于点击【启动监听】按钮时调用。
MainWindow
类头文件定义如下:
#pragma once
#include <QMainWindow>
#include <QStandardItemModel>
#include "SCPServer.h"
QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
void on_btnListen_clicked();
void on_btnBrowse_clicked();
private:
Ui::MainWindow *ui;
QStandardItemModel m_tableModel;
SCPServer* m_pServer = nullptr;
void customEvent(QEvent* event);
void showLog(QString strLog);
bool startListen(QString strListenIP, uint nListenPort);
bool stopListen();
};
MainWindow
构造函数代码如下:
#define LTV17_CONFIG
#include "L_Bitmap.h"
#include "Ltdic.h"
#include "classlib\LtWrappr.h"
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
this->setWindowTitle("DICOM C-STORE SCP Demo");
ui->cmbAddress->addItem("127.0.0.1");
QHostInfo hostInfo = QHostInfo::fromName(QHostInfo::localHostName());
for (QHostAddress address : hostInfo.addresses()) {
if (address.protocol() == QAbstractSocket::IPv4Protocol) {
ui->cmbAddress->addItem(address.toString());
}
}
QString picturesLocation = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
ui->edtImagePath->setText(picturesLocation);
ui->tableView->setModel(&m_tableModel);
QStringList tableHeaders;
tableHeaders.append("时间");
tableHeaders.append("信息");
m_tableModel.setHorizontalHeaderLabels(tableHeaders);
ui->tableView->setColumnWidth(0, 180);
ui->tableView->setColumnWidth(1, 1000);
ui->tableView->setSelectionBehavior(QAbstractItemView::SelectRows);
ui->tableView->setSelectionMode(QAbstractItemView::SelectionMode::SingleSelection);
// leadtools network 初始化
UNLOCKSUPPORT();
LBase::LoadLibraries(LT_DIS | LT_FIL | LT_IMG | LT_KRN);
L_INT nError = LDicomNet::StartUp();
if (nError != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nError);
QString strError = QString("初始化 DICOM Network 失败,原因:%1").arg(dcmError);
showLog(strError);
QMessageBox::critical(this, "错误", strError);
}
}
注意这里使用了 QHostInfo
和 QHostAddress
类,需要在项目文件 CStoreSCPDemo.pro
里添加 network
模块,否则无法编译通过。
startListen
函数代码如下:
bool MainWindow::startListen(QString strListenIP, uint nListenPort)
{
QString strInfo = QString("正在启动 DICOM 监听(IP: %1,Port: %2)...").arg(strListenIP).arg(nListenPort);
showLog(strInfo);
m_pServer = new SCPServer();
if (m_pServer == nullptr) {
QString strError = QStringLiteral("创建 SCP Server 对象失败。");
showLog(strError);
QMessageBox::critical(this, "错误", strError);
return false;
}
m_pServer->registerLogger(this);
m_pServer->setImageFolder(ui->edtImagePath->text());
m_pServer->setServerIPPort(strListenIP, nListenPort);
L_TCHAR* pszListenIP = (L_TCHAR*)reinterpret_cast<const L_TCHAR*>(strListenIP.utf16());
int nRet = m_pServer->Listen(pszListenIP, nListenPort, 5);
if (nRet != DICOM_SUCCESS) {
QString dcmError = DicomError::instance().text(nRet);
QString strError = QString("启动监听(%1:%2)失败,原因:%3。").arg(strListenIP).arg(nListenPort).arg(dcmError);
showLog(strError);
delete m_pServer;
m_pServer = nullptr;
QMessageBox::critical(this, "错误", strError);
return false;
}
return true;
}
编译程序
编译程序前,先在项目文件 CStoreSCPDemo.pro
里添加下面三个选项:
MOC_DIR = temp/moc
UI_DIR = temp/ui
OBJECTS_DIR = temp/obj
默认设置,编译过程中生成的临时文件会与可执行文件放在同一个文件夹下,加上这三个选项后,会把临时文件分类放在不同的目录里。
点击界面左下角的【锤子】图标,编译程序。
我们会发现,在【问题】窗口出现很多错误提示,大致有下面几种:
- C1057: 宏扩展中遇到意外的文件结束
- C2001: 常量中有换行符
- C2143: 语法错误: 缺少“)”(在“}”的前面)
- C2146: 语法错误: 缺少“)”(在标识符“SendAssociateReject”的前面)
- C2664: “L_VOID L_UnlockSupport(L_UINT,L_TCHAR *)”: 无法将参数 2 从“const wchar_t [11]”转换为“L_TCHAR *”
错误虽然很多,但绝大部分(C1057、C2001、C2143、C2146)都是字符编码导致的问题,原因在于我们使用了 MSVC 编译器。
MSVC 编译器通过源码文件的 BOM 头识别字符编码,如果源码文件没有 BOM 头,那么就取操作系统的默认字符编码。
这样问题就来了,Qt Creator 生成的文件,默认是没有 BOM 头的,MSVC 检测不到 BOM 头,就取了操作系统的默认字符编码。
本程序的开发环境是 Windows 11 中文版,所有 Windows 中文版的默认字符编码都是 GBK,所以 MSVC 使用了 GBK 作为字符编码来编译程序。而 Qt Creator 生成的文件,默认字符编码是 UTF-8,所以产生了错误。
对于英文来说,GBK 与 UTF-8 的字符编码是相同的,不会产生错误,因此我们会发现,出现错误的地方,都是有中文的地方。
要解决这个错误,我们可以修改 Qt Creator 的 UTF-8 BOM 选项,给所有文件都加上 BOM 头。
通过菜单【工具 > 选项】打开选项窗口,再选择【文本编辑器 > Behavior】,将 UTF-8 BOM 选项的值改为 如果编码是UTF-8则添加
。
这样修改后,对于有错误的文件,还要重新保存,以便生成 BOM 头。然后再重新编译程序。
这个方法虽然能够解决问题,但如果有错误的文件太多的话,逐个重新保存也很麻烦。而且修改了 Qt Creator 的选项后,不只影响当前项目,对于以后编写的程序,都会产生影响。所以不推荐这个做法。
除了上面的方法外,我们还可以通过在项目文件 CStoreSCPDemo.pro
里添加 QMAKE 选项来解决问题:
QMAKE_CXXFLAGS += /utf-8
添加上面这个选项后,再重新编译程序,会发现只剩 C2664 错误了。这个错误也可以通过添加编译器选项解决:
QMAKE_CXXFLAGS -= -Zc:strictStrings
运行程序
点击界面左下角的绿色【箭头】图标,运行程序,会出现这样的提示:
共有 Ltdicu.dll、Ltkrnu.dll、Ltwvcu.dll 三个文件找不到。
我们到 E:\LeadTools 17\Bin\CDLL\Win32
下面找到这三个文件,然后复制到 D:\build-CStoreSCPDemo-Desktop_Qt_5_15_2_MSVC2019_32bit-Debug\debug
目录里,再重新运行程序。
发布与部署
上面运行程序的方法,是直接在 Qt Creator 里启动程序。而实际发布程序,是不可能带着开发环境一起发布的。
真正发布与部署程序,我们都是将编译好的 .exe 文件与所需的 Qt 运行库一同发布,执行程序时,也是直接运行 .exe 文件。
那么问题来了,Qt 运行库有很多 DLL 文件,我们怎么知道到底需要哪些 DLL 呢?其实对于这个问题,Qt 提供了一个很好用的小工具:Qt Windows Deploy Tool。
打开 命令提示符 窗口,进入 C:\Qt\5.15.2\msvc2019\bin
文件夹,执行 windeployqt.exe D:\build-CStoreSCPDemo-Desktop_Qt_5_15_2_MSVC2019_32bit-Debug\debug\CStoreSCPDemo.exe
命令,Qt 部署工具会检查目标文件的依赖关系,然后将所依赖的 Qt 库文件复制到目标文件夹。
测试程序
程序编译通过,仅是完成了第一步,程序是不是好用,有没有错误,还是要经过测试才知道。
本程序是一个 C-Store SCP,用于接收 C-Store SCU 发送来的图像。所以我们需要找一个 C-Store SCU 程序,真正的发送图像试一试。
这里我们使用 DCMTK 附带的工具 storescu.exe
,下载地址:https://dicom.offis.de/download/dcmtk/dcmtk367/bin/dcmtk-3.6.7-win32-dynamic.zip
下载后,将 dcmtk-3.6.7-win32-dynamic.zip
解压到 D:\dcmtk-3.6.7-win32-dynamic
文件夹。
测试还需要图像,我们使用《第一个 DCMTK 程序:显示 DICOM 图像(DCMTK 3.6.4 + Qt 5.14.2 + VS2015)》一文所使用的图像,下载地址 https://download.csdn.net/download/blackwoodcliff/13193834
图像下载后,解压到 E:\MR20200821
文件夹。
准备工作完成后,就可以开始测试了。
首先,双击 D:\build-CStoreSCPDemo-Desktop_Qt_5_15_2_MSVC2019_32bit-Debug\debug\CStoreSCPDemo.exe
运行程序,点击界面上的【启动监听】按钮,启动 DICOM 监听。
然后,打开 命令提示符 窗口,进入 D:\dcmtk-3.6.7-win32-dynamic\bin
文件夹,输入下面命令:
storescu.exe 127.0.0.1 6666 E:\MR20200821\MR01010001.dcm --propose-lossless
CStoreSCPDemo.exe
收到 storescu.exe
的请求,响应如下:
说明:上面的 storescu
命令里指定了传输语法 --propose-lossless
,这是因为我们所用的测试图像的 Transfer Syntax UID 是 1.2.840.10008.1.2.4.70 (JPEG Lossless, Nonhierarchical, First - Order Prediction (Processes 14[Selection Value 1]))
上面的测试,只发送了一幅图像。我们也可以同时发送指定文件夹里的全部图像,命令如下:
storescu.exe 127.0.0.1 6666 --scan-directories E:\MR20200821 --propose-lossless
界面美化
其实这一步可有可无,上面的程序,功能已经很完整了。但鉴于 QSS 简单易用,我们就再做点锦上添花的工作。
编写一个 QSS 文件,命名为 style.qss
,内容如下:
QMainWindow {
background-color: #07304f;
}
QPushButton {
border-radius: 4px;
background-color: #092542;
border: 1px solid #2dbdff;
color: white;
padding-top: 6px;
padding-bottom: 6px;
padding-left: 12px;
padding-right: 12px;
font-family: Microsoft YaHei;
}
QPushButton:hover {
background-color: #2dbdff;
}
/* pressed 必须放在 hover 之后,否则 QComboBox::drop-down 会使 QPushButton:pressed 失效 */
QPushButton:pressed {
background-color: #0b5ed7;
color: white;
}
QLineEdit {
border: 2px solid #07304f;
border-radius: 4px;
padding: 3px 6px;
font-family: Microsoft YaHei;
}
QLineEdit:focus {
border: 2px solid #2dbdff;
}
QComboBox {
border: 2px solid #07304f;
border-radius: 4px;
padding: 3px 6px;
font-family: Microsoft YaHei;
}
QComboBox:focus {
border: 2px solid #2dbdff;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 15px;
border-left-width: 1px;
border-left-color: darkgray;
border-left-style: solid;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
QComboBox::down-arrow {
image: url(arrow_down.svg);
}
修改 main.cpp
,在 QApplication a(argc, argv);
一行下面,添加如下代码:
QFile styleFile("style.qss");
if (styleFile.open(QIODevice::ReadOnly|QIODevice::Text)) {
QString styleSheet(styleFile.readAll());
qApp->setStyleSheet(styleSheet);
styleFile.close();
}
重新编译并运行程序,效果如下:
参考
- DICOM医学图像处理:DICOM网络传输
- LDicomNet
- LDicomAssociate
- Creating a DICOM Network Connection
- Creating an SCP