• Switch to Warm
  • Switch to Cool
  • Workspace
  • Projects
  • Notes

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 vtkImageData volume.

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 vtkDICOMDirectory and vtkDICOMReader locally. The controller’s member copies (m_dir, m_reader) are only written on the main thread inside the QMetaObject::invokeMethod callback.
  • Guard against stale threads. If a second LoadDicom call arrives while the first is still running, the first thread is already deleted via finished → deleteLater, but m_workerThread must point to the new one. The identity check (if (m_workerThread == workerThread)) handles this.
  • vtkDICOMDirectory doesn’t need GDcm. VTK’s built-in DICOM classes handle file parsing and metadata extraction natively. GDcm is an alternative, but when VTK already provides vtkDICOMReader and vtkDICOMDirectory, adding GDcm means maintaining two DICOM stacks for no benefit.
  • Sorting is implicit. vtkDICOMDirectory returns 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.