This tutorial shows how to create a simple PACS Server that handles connection, association, storage, echo, and release requests from a PACS client in a C# WinForms application using the LEADTOOLS SDK.
Overview | |
---|---|
Summary | This tutorial covers how create a simple PACS server in a WinForms C# Application. |
Completion Time | 60 minutes |
Visual Studio Project | Download tutorial project (14 KB) |
Platform | Windows WinForms C# Application |
IDE | Visual Studio 2017, 2019 |
Development License | Download LEADTOOLS |
Get familiar with the basic steps of creating a project by reviewing the Add References and Set a License tutorial, before working on the Create a Simple PACS Server - WinForms C# tutorial,
In Visual Studio, create a new C# Windows Winforms project, and add the below necessary LEADTOOLS references.
The references needed depend upon the purpose of the project. References can be added by one or the other of the following two methods (but not both).
If using NuGet references, this tutorial requires the following NuGet package:
Leadtools.Dicom.Pacs.Scp
If using local DLL references, the following DLLs are needed.
The DLLs are located at <INSTALL_DIR>\LEADTOOLS21\Bin\Dotnet4\x64
:
Leadtools.dll
Leadtools.Dicom.dll
Leadtools.Dicom.Tables.dll
For a complete list of which DLL files are required for your application, refer to Files to be Included With Your Application.
The License unlocks the features needed for the project. It must be set before any toolkit function is called. For details, including tutorials for different platforms, refer to Setting a Runtime License.
There are two types of runtime licenses:
Note
Adding LEADTOOLS NuGet and local references and setting a license are covered in more detail in the Add References and Set a License tutorial.
With the project created, the references added, and the license set, coding can begin.
In the Solution Explorer, Right-click on <Project>.csproj
and select Add -> New Item. Select the Class
option and name the class Server.cs
, then click Add.
The Server
class is an extension of the DicomNet
class and will implement server methods to listen for and handle Accept and Close requests from a listening connection.
The Server
class will include the following properties:
UidInclusionList
string collection which will act as a whitelist to hold the UIDs supported by this server.Clients
collection that will hold the peer address and port of each connected client.Add the using
statements to the top of the Server
class:
// Using block at the top
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Runtime.InteropServices;
using Leadtools.Dicom;
Add the code below to define the public and private properties of the Server
class along with its constructor.
namespace Create_a_Simple_PACS_Server
{
public class Server : DicomNet
{
public Form1 MainForm;
public bool ServerFinished = false;
// Collection of DICOM UID's supported by the server
private StringCollection _UidInclusionList = new StringCollection();
public StringCollection UidInclusionList => _UidInclusionList;
// Collection of connected Clients
private Dictionary<string, ClientConnection> _Clients = new Dictionary<string, ClientConnection>();
public Dictionary<string, ClientConnection> Clients { get { return _Clients; } }
// Server Properties
private string _CalledAE;
private string _IPAddress = "";
private int _Port = 104;
private string _storeDirectory = string.Empty;
public string CalledAE { get { return _CalledAE; } set { _CalledAE = value; } }
public string IPAddress { get { return _IPAddress; } set { _IPAddress = value; } }
public int Port { get { return _Port; } set { _Port = value; } }
public string StoreDirectory { get { return _storeDirectory; } set { _storeDirectory = value; } }
public Server(string path, DicomNetSecurityMode mode) : base(path, mode)
{
BuildInclusionList();
StoreDirectory = path;
}
}
}
Use the code below to define the IODs
that will be supported by this PACS server. This will function as a whitelist to only allow connections that use the included UIDs
.
private void BuildInclusionList()
{
UidInclusionList.Add(DicomUidType.ImplicitVRLittleEndian);
UidInclusionList.Add(DicomUidType.ExplicitVRLittleEndian);
UidInclusionList.Add(DicomUidType.ExplicitVRBigEndian);
UidInclusionList.Add(DicomUidType.VerificationClass);
UidInclusionList.Add(DicomUidType.SCImageStorage);
UidInclusionList.Add(DicomUidType.USImageStorage);
UidInclusionList.Add(DicomUidType.USImageStorageRetired);
UidInclusionList.Add(DicomUidType.USMultiframeImageStorage);
UidInclusionList.Add(DicomUidType.USMultiframeImageStorageRetired);
UidInclusionList.Add(DicomUidType.CTImageStorage);
UidInclusionList.Add(DicomUidType.JPEGBaseline1);
UidInclusionList.Add(DicomUidType.JPEGExtended2_4);
UidInclusionList.Add(DicomUidType.JPEGLosslessNonhier14B);
UidInclusionList.Add(DicomUidType.EnhancedMRImageStorage);
UidInclusionList.Add(DicomUidType.DXImageStoragePresentation);
}
The code below will handle an Accept request from a connected client. Provided that there are no errors in the connection request, this code will accept the connection and add the client connection's information to the Clients
collection.
protected override void OnAccept(DicomExceptionCode error)
{
ClientConnection client;
if (error != DicomExceptionCode.Success)
{
Log(string.Format("Error in OnAccept with DicomExceptionCode: {0}", error.ToString()));
SendAbort(DicomAbortSourceType.Provider, DicomAbortReasonType.Unknown);
}
client = new ClientConnection(this);
try
{
Accept(client);
}
catch (Exception ex)
{
Log(string.Format("Connection rejected : {0}", ex.Message));
client.Close();
return;
}
if (!Clients.ContainsKey(client.PeerAddress + "_" + client.PeerPort))
{
Clients.Add(client.PeerAddress + "_" + client.PeerPort, client);
}
else
{
Log("Connection rejected. IP already connected: " + client.PeerAddress);
client.Close();
return;
}
}
The following code will handle a Close request from a connected client. This will remove the connection's information from the Clients
collection and dispose the relevant objects.
protected override void OnClose(DicomExceptionCode error, DicomNet net)
{
if (net.Association != null)
Log(string.Format("Closing connection with client: {0}", net.Association.Calling));
else
Log("Closing connection with client");
if (Clients.ContainsKey(net.PeerAddress + "_" + net.PeerPort))
{
ClientConnection client = Clients[net.PeerAddress + "_" + net.PeerPort];
Clients.Remove(net.PeerAddress + "_" + net.PeerPort);
client.Dispose();
}
else
{
net.Dispose();
}
}
The Wait()
method allows for Windows messaging to continue while the server is listening and doing connection operations.
The Log()
method is used to write logging messages on the Main
form.
public bool Wait()
{
do
{
Breathe();
} while (!ServerFinished);
return true;
}
private void Log(string message)
{
MainForm.Log(message);
}
}
}
In the Solution Explorer, Right-click on <Project>.csproj
and select Add -> New Item. Select the Class
option and name the class ClientConnection.cs
, then click Add.
The ClientConnection
class is an extension of the DicomNet
class as is the Server
class.
ClientConnection
implements the handling of Associate, Echo, Store, and Release requests sent by a connected client.
Add the using
statements below to the top.
// Using block at the top
using System;
using System.Timers;
using Leadtools.Dicom;
Use the code below to set the properties and constructor of the ClientConnection
class.
The properties include an instance of the Server
class that has been created with the server information, along with a DicomTimer
object defined in the code block below. DicomTimer
is an extension of a System.Timers.Timer
object.
namespace Create_a_Simple_PACS_Server
{
public class ClientConnection : DicomNet
{
private Server _server;
private DicomTimer _connectionTimer;
public DicomTimer connectionTimer { get { return _connectionTimer; } }
public ClientConnection(Server server) : base(null, Leadtools.Dicom.DicomNetSecurityMode.None)
{
this._server = server;
_connectionTimer = new DicomTimer(this, 30);
_connectionTimer.Elapsed += ConnectionTimer_Elapsed;
}
}
}
The code for this method handles any error that occurs and aborts the connection.
protected override void OnReceive(DicomExceptionCode error, DicomPduType pduType, IntPtr buffer, int bytes)
{
if (error != DicomExceptionCode.Success)
{
Log(string.Format("Error in OnReceive with DicomExceptionCode: {0}", error.ToString()));
SendAbort(DicomAbortSourceType.Provider, DicomAbortReasonType.Unexpected);
}
connectionTimer.Stop();
base.OnReceive(error, pduType, buffer, bytes);
connectionTimer.Start();
}
The following code handles association requests sent by a connected client to a server. The method derives its own association request to make sure that it conforms to the specifications of the server. This includes only allowing Transfer Syntax UIDs that are allowed in the UidInclusionList
of the server.
protected override void OnReceiveAssociateRequest(DicomAssociate association)
{
Log(string.Format("Associate request received from: {0}", association.Calling));
bool minOnePresentationContextSupported = false;
using (DicomAssociate retAssociate = new DicomAssociate(false))
{
// Build the Association Request
retAssociate.Called = _server.CalledAE;
retAssociate.Calling = association.Calling;
retAssociate.ImplementClass = "1.2.840.114257.1123456";
retAssociate.ImplementationVersionName = "1";
retAssociate.MaxLength = 46726;
retAssociate.Version = 1;
retAssociate.ApplicationContextName = (string)DicomUidType.ApplicationContextName;
for (int i = 0; i < association.PresentationContextCount; i++)
{
byte id = association.GetPresentationContextID(i);
string abstractSyntax = association.GetAbstract(id);
retAssociate.AddPresentationContext(id, DicomAssociateAcceptResultType.Success, abstractSyntax);
if (IsSupported(abstractSyntax))
{
for (int j = 0; j < association.GetTransferCount(id); j++)
{
string transferSyntax = association.GetTransfer(id, j);
if (IsSupported(transferSyntax))
{
minOnePresentationContextSupported = true;
retAssociate.AddTransfer(id, transferSyntax);
break;
}
}
if (retAssociate.GetTransferCount(id) == 0)
{
// Presentation id doesn't have any abstract syntaxes therefore we will reject it.
retAssociate.SetResult(id, DicomAssociateAcceptResultType.AbstractSyntax);
}
}
else
{
retAssociate.SetResult(id, DicomAssociateAcceptResultType.AbstractSyntax);
}
}
if (association.MaxLength != 0)
{
retAssociate.MaxLength = association.MaxLength;
}
if (minOnePresentationContextSupported)
{
SendAssociateAccept(retAssociate);
Log("Sending Associate Accept");
}
else
{
SendAssociateReject(DicomAssociateRejectResultType.Permanent,
DicomAssociateRejectSourceType.Provider2,
DicomAssociateRejectReasonType.Application);
Log("Sending Associate Reject - Abstract or transfer syntax not supported");
}
}
}
The following code handles any Echo requests sent by a connected client.
protected override void OnReceiveCEchoRequest(byte presentationID, int messageID, string affectedClass)
{
Log(string.Format("C-ECHO Request received from: {0}", this.Association.Calling));
byte id;
if (DicomUidTable.Instance.Find(affectedClass) == null)
{
SendCEchoResponse(presentationID, messageID, affectedClass, DicomCommandStatusType.ClassNotSupported);
Log(string.Format("C-ECHO response sent to \"{0}\" (Class Not Supported)", this.Association.Calling));
}
id = this.Association.FindAbstract(affectedClass);
if (id == 0 || this.Association.GetResult(id) != DicomAssociateAcceptResultType.Success)
{
SendCEchoResponse(presentationID, messageID, affectedClass, DicomCommandStatusType.ClassNotSupported);
Log(string.Format("C-ECHO response sent to \"{0}\" (Class Not Supported)", this.Association.Calling));
}
SendCEchoResponse(presentationID, messageID, affectedClass, DicomCommandStatusType.Success);
Log(string.Format("C-ECHO response sent to \"{0}\" (Success)", this.Association.Calling));
}
The code below handles Store requests sent by a client. Provided that the association request was successfully accepted and a supported Abstract Syntax is found, the method stores the passed DICOM file into the server's Store Directory.
protected override void OnReceiveCStoreRequest(byte presentationID, int messageID, string affectedClass, string instance, DicomCommandPriorityType priority, string moveAE, int moveMessageID, DicomDataSet dataset)
{
DicomCommandStatusType status = DicomCommandStatusType.Failure;
if (this.Association == null)
{
Log("Association is invalid");
this.SendAbort(DicomAbortSourceType.Provider, DicomAbortReasonType.Unexpected);
return;
}
Log(string.Format("C-STORE request received:\nCalled: {0}\nCaller: {1}", Association.Called, Association.Calling));
DicomUid dicomUID = DicomUidTable.Instance.Find(affectedClass);
if (dicomUID == null)
status = DicomCommandStatusType.ClassNotSupported;
else
{
byte id = Association.FindAbstract(affectedClass);
while (((id != 0) && (this.Association.GetResult(id) != DicomAssociateAcceptResultType.Success)))
id = this.FindNextAbstract(Association, id, affectedClass);
if (id == 0)
{
Log(string.Format("Abstract syntax not supported: {0}\n\t{1}", dicomUID.Code, dicomUID.Name));
status = DicomCommandStatusType.ClassNotSupported;
}
else
status = DicomCommandStatusType.Success;
}
// All checks passed
// Save the file
if (status == DicomCommandStatusType.Success)
{
status = DicomCommandStatusType.Failure;
DicomElement dicomElement = dataset.FindFirstElement(null, DicomTag.SOPInstanceUID, true);
if (dicomElement != null)
{
string SOPInstanceUID = dataset.GetStringValue(dicomElement, 0);
if (!string.IsNullOrEmpty(SOPInstanceUID))
{
string fileName = string.Format("{0}//{1}.dcm", _server.StoreDirectory, SOPInstanceUID);
try
{
dataset.Save(fileName, DicomDataSetSaveFlags.None);
status = DicomCommandStatusType.Success;
}
catch (Exception ex)
{
Log(string.Format("C-STORE request exception during save:\n{0}\n{1}", ex.Message, ex.StackTrace));
}
}
dataset.FreeElementValue(dicomElement);
}
}
this.SendCStoreResponse(presentationID, messageID, affectedClass, instance, status);
Log(string.Format("C-STORE response sent ({0})", status));
}
The following code handles any Release requests sent by a connected client.
protected override void OnReceiveReleaseRequest()
{
if (this.Association != null)
{
Log(string.Format("Received Release Request: {0}", this.Association.Calling == null ? this.Association.Calling : @"N/A"));
Log(string.Format("Sending release response: {0}", this.Association.Calling == null ? this.Association.Calling : @"N/A"));
}
else
{
Log("Received Release Request");
Log("Sending release response");
}
SendReleaseResponse();
connectionTimer.Stop();
}
The IsSupported()
method is used to check a UID to see if it is supported by the default DICOM table and the server's inclusion list.
private bool IsSupported(string uid)
{
bool supported = false;
if (DicomUidTable.Instance.Find(uid) == null)
{
Log(string.Format("UID not supported: {0}", uid));
return false;
}
if (_server.UidInclusionList.Contains(uid))
{
Log(string.Format("UID supported: {0}", uid));
supported = true;
}
return supported;
}
The FindNextAbstract()
method is used to find a supported Abstract Syntax in the association.
private byte FindNextAbstract(DicomAssociate dicomPDU, int id, string uid)
{
if (uid == null)
return 0;
int presentationCount = dicomPDU.PresentationContextCount;
int lastPresentation = 2 * presentationCount - 1;
while (id <= lastPresentation)
{
id = id + 1;
string szAbstract = dicomPDU.GetAbstract(byte.Parse(id.ToString()));
if (string.Equals(uid, szAbstract))
{
return byte.Parse(id.ToString());
}
}
return 0;
}
The ConnectionTimer_Elapsed()
method is used with the DicomTimer
class and aborts and closes a connection after the specified interval of inactivity is reached.
private void ConnectionTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (IsConnected())
{
Log("Connection timed out. Aborting connection.");
SendAbort(DicomAbortSourceType.Provider, DicomAbortReasonType.Unknown);
this.Close();
connectionTimer.Stop();
connectionTimer.Dispose();
}
}
The Log()
method allows logging messages to be displayed in the Main
form.
private void Log(string message)
{
_server.MainForm.Log(message);
}
The DicomTimer
class is an extension of the System.Timers.Timer
object and it is used to check the connection and trigger the ConnectionTimer_Elapsed()
handler to close and abort a connection after inactivity.
public class DicomTimer : Timer
{
private ClientConnection _Client;
public ClientConnection Client { get { return _Client; } }
public DicomTimer(ClientConnection client, int time)
{
_Client = client;
Interval = (time * 1000);
}
}
Add a call to InitServer()
in the Form1()
method after the SetLicense()
call. This calls a method to create an instance of the Server
class and initialize it with the valid server information.
// Add to global variables for the form
private Server pacsServer;
// Server Properties
private string serverAE = "DCM_SERVER";
private string serverIP = "0.0.0.0"; // Use valid IP
private string serverPort = "0"; // Use valid port number
private string storeDirectory = @"X:\xx"; //Use valid directory
public Form1()
{
InitializeComponent();
SetLicense();
InitServer();
}
private void InitServer()
{
try
{
DicomEngine.Startup();
pacsServer = new Server(storeDirectory, DicomNetSecurityMode.None);
pacsServer.MainForm = this;
pacsServer.CalledAE = serverAE;
pacsServer.IPAddress = serverIP;
pacsServer.Port = Int32.Parse(serverPort);
}
catch(Exception ex)
{
MessageBox.Show(ex.Message);
}
}
Open Form1.cs
in the Solution Explorer. In the Designer, add a Server dropdown menu item, using the MenuStrip tool. Add two new items to the Server dropdown, with their text property set to &Start and St&op. Leave the new items' names as startToolStripMenuItem
and stopToolStripMenuItem
.
Double-click the Start
and Stop
menu items to create their individual event handlers. This will bring up the code behind the form.
Add the following code to the startToolStripMenuItem_Click
event handler.
private void startToolStripMenuItem_Click(object sender, EventArgs e)
{
try
{
DicomNet.Startup();
pacsServer.ServerFinished = false;
pacsServer.Listen(serverIP, Int32.Parse(serverPort), 1);
Log(string.Format($"{serverAE} has started listening on {serverIP}:{serverPort}"));
pacsServer.Wait();
Log(string.Format($"{serverAE} has stopped listening"));
}
catch(Exception ex)
{
MessageBox.Show(ex.Message);
}
}
Add the following code to the stopToolStripMenuItem_Click
event handler.
private void stopToolStripMenuItem_Click(object sender, EventArgs e)
{
try
{
pacsServer.ServerFinished = true;
pacsServer.Close();
DicomNet.Shutdown();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
Navigate back to the Designer
and add a loggingTextBox
TextBox control.
Change the following properties for this TextBox control to:
Right-click the Designer
and select View Code, or press F7, to bring up the code behind the form. Create a new method in the Form1
class named Log(string message)
. Add the code below to display logging messages from the Server
and ClientConnection
classes in the TextBox.
public void Log(string message)
{
if (InvokeRequired)
{
this.Invoke(new MethodInvoker(delegate
{
loggingTextBox.Text += message + "\r\n";
}));
}
else
loggingTextBox.Text += message + "\r\n";
}
Run the project by pressing F5, or by selecting Debug -> Start Debugging.
If the steps were followed correctly, the application runs and allows the user to initialize and start a simple PACS server that handles connection, association, echo, and storage requests from connected clients.
The LEADTOOLS High-level Store
demo can be used to test the functions of this server. This demo can be found here:
<INSTALL_DIR>\LEADTOOLS21\Shortcuts\PACS\.NET Framework Class Libraries\PACS Framework (High Level)\DICOM High-level Store\
The configuration of the default demo PACS servers can be skipped.
This server's connection information can be added to the connection list through the Options... button.
This tutorial showed how to use the DicomNet
class to create a PACS server that can handle connection and storage requests from PACS clients. In addition, it showed how to use the DicomScp
class.