Loading DICOM Series Asynchronously with VTK and QThread
Tutorial
Published Feb 14, 2026 · Updated Jul 21, 2026
Part of: XQMprViewer
Step-by-step walkthrough for loading DICOM directories off the main thread
using VTK’s native DICOM classes and QThread::create, with two-phase
loading (scan, then load) and thread-safe result delivery via
QMetaObject::invokeMethod. This is the pattern behind the async DICOM
loader in the realtime medical imaging system and XQMprViewer.
1. Design the two-phase loading model
DICOM directories can contain multiple series (different scan types, different sequences). Loading everything upfront is wasteful — the user usually wants one series. The pattern splits loading into two phases:
- Scan directory — enumerate all series, extract metadata (description, instance UID, file count), return a list for the UI to present.
- Load series — given a series index, read the files and return a
vtkImageDatavolume.
Each phase runs on its own temporary thread. The controller owns the VTK objects and manages thread lifecycle; no custom QObject worker class is needed.
2. Define the controller interface
The controller owns the VTK readers and emits signals when results are ready.
The Task enum tracks which phase is running:
class DicomController : public IControllerBase {
Q_OBJECT
public:
void LoadDicom(const std::string& directory);
void LoadSeries(int seriesIndex);
signals:
void SeriesListReady(int count);
void ImageDataReady(vtkSmartPointer<vtkImageData> image);
void StatusChanged(const QString& message);
private:
enum class Task { ScanDirectory, LoadSeries };
void _run();
vtkSmartPointer<vtkDICOMDirectory> m_dir;
vtkSmartPointer<vtkDICOMReader> m_reader;
vtkSmartPointer<vtkImageData> m_imageData;
QThread* m_workerThread = nullptr;
std::string m_currentDirectory;
int m_currentSeries = -1;
Task m_task;
std::vector<SeriesInfo> m_series;
};
3. Run the work on a background thread
Use QThread::create (Qt 5.10+) with a lambda that captures the controller’s
state by value. No custom QObject subclass, no moveToThread — just a
lambda and the VTK objects it needs:
void DicomController::_run() {
const std::string directory = m_currentDirectory;
const Task task = m_task;
const int seriesIndex = m_currentSeries;
m_workerThread = QThread::create([this, directory, task, seriesIndex]() {
auto dir = vtkSmartPointer<vtkDICOMDirectory>::New();
dir->SetDirectoryName(directory.c_str());
dir->SetScanDepth(8);
dir->Update();
// ... enumerate series, or load a specific series ...
});
QThread* workerThread = m_workerThread;
connect(workerThread, &QThread::finished,
workerThread, &QObject::deleteLater);
connect(workerThread, &QThread::finished, this,
[this, workerThread]() {
if (m_workerThread == workerThread) {
m_workerThread = nullptr;
}
});
workerThread->start();
}
The finished handler nulls out m_workerThread only if the finished thread
is still the current one — if a second load request arrived before the first
finished, the first thread is still deleted, but m_workerThread already
points to the second one.
4. Deliver results back to the main thread
VTK objects (vtkSmartPointer, vtkImageData) are not thread-safe for
concurrent access, but passing ownership across threads is safe as long as
only one thread touches the object at a time. The delivery mechanism is
QMetaObject::invokeMethod with Qt::QueuedConnection — it serializes the
lambda through the receiver’s event loop, which runs on the main thread:
// Inside the background lambda, after loading:
auto reader = vtkSmartPointer<vtkDICOMReader>::New();
reader->SetFileNames(files);
reader->Update();
auto imageData = vtkSmartPointer<vtkImageData>::New();
imageData->DeepCopy(reader->GetOutput());
QMetaObject::invokeMethod(
this,
[this, dir, reader, imageData]() {
m_dir = dir;
m_reader = reader;
m_imageData = imageData;
emit ImageDataReady(m_imageData);
emit StatusChanged(tr("DICOM data ready."));
},
Qt::QueuedConnection);
QMetaObject::invokeMethod is preferred over direct signal/slot connections
across threads here because the controller itself isn’t moved to a different
thread — it stays on the main thread, and the invocation mechanism handles
the thread boundary explicitly. The lambda captures reader and imageData
by value (smart pointer copies), transferring ownership to the main thread
without races.
5. The scan phase — enumerate series metadata
The scan phase uses vtkDICOMDirectory to discover series and extract
metadata. Results are delivered back to the main thread the same way:
// Inside the background lambda (ScanDirectory path):
std::vector<SeriesInfo> seriesList;
int flatSeriesIndex = 0;
const int studyCount = dir->GetNumberOfStudies();
for (int study = 0; study < studyCount; ++study) {
const int firstSeries = dir->GetFirstSeriesForStudy(study);
const int lastSeries = dir->GetLastSeriesForStudy(study);
for (int series = firstSeries; series <= lastSeries; ++series) {
vtkStringArray* files = dir->GetFileNamesForSeries(series);
if (!files || files->GetNumberOfValues() == 0) continue;
SeriesInfo info;
info.index = flatSeriesIndex;
info.dicomSeriesIndex = series;
info.fileCount = static_cast<int>(files->GetNumberOfValues());
vtkDICOMMetaData* meta = dir->GetMetaDataForSeries(series);
if (meta && meta->Has(DC::SeriesDescription)) {
info.description = meta->Get(DC::SeriesDescription).AsString();
}
seriesList.push_back(std::move(info));
++flatSeriesIndex;
}
}
QMetaObject::invokeMethod(
this,
[this, dir, seriesList = std::move(seriesList)]() mutable {
m_dir = dir;
m_series = std::move(seriesList);
emit SeriesListReady(static_cast<int>(m_series.size()));
if (m_series.size() == 1) {
QTimer::singleShot(0, this, [this]() {
LoadSeries(0);
});
}
},
Qt::QueuedConnection);
When only one series exists, LoadSeries(0) fires automatically via
QTimer::singleShot(0, ...) — deferred to the next event loop iteration so
the signal handlers from SeriesListReady have connected first.
Common pitfalls
- Don’t reuse VTK objects across threads. Each background invocation
creates its own
vtkDICOMDirectoryandvtkDICOMReaderlocally. The controller’s member copies (m_dir,m_reader) are only written on the main thread inside theQMetaObject::invokeMethodcallback. - Guard against stale threads. If a second
LoadDicomcall arrives while the first is still running, the first thread is already deleted viafinished → deleteLater, butm_workerThreadmust point to the new one. The identity check (if (m_workerThread == workerThread)) handles this. vtkDICOMDirectorydoesn’t need GDcm. VTK’s built-in DICOM classes handle file parsing and metadata extraction natively. GDcm is an alternative, but when VTK already providesvtkDICOMReaderandvtkDICOMDirectory, adding GDcm means maintaining two DICOM stacks for no benefit.- Sorting is implicit.
vtkDICOMDirectoryreturns files sorted by instance number. You don’t need to sort alphabetically and hope the naming convention holds — VTK handles the DICOM tag-based ordering.