changeset 50:3c0e19a754a1

added missing header
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 22 Oct 2025 10:40:29 +0200
parents 2daaa1067e72 (diff) 7a0157cbfe02 (current diff)
children 87429291063c
files Sources/Dicomization/DicomizationJob.cpp
diffstat 18 files changed, 1081 insertions(+), 644 deletions(-) [+]
line wrap: on
line diff
--- a/.reuse/dep5	Wed Oct 22 10:24:53 2025 +0200
+++ b/.reuse/dep5	Wed Oct 22 10:40:29 2025 +0200
@@ -15,6 +15,7 @@
 License: CC0-1.0
 
 Files:
+  Resources/CMake/Reproc.patch
   WebApplication/*.html
 Copyright: 2024-2025 Sebastien Jodogne, EPL UCLouvain (Belgium)
 License: AGPL-3.0-or-later
--- a/CMakeLists.txt	Wed Oct 22 10:24:53 2025 +0200
+++ b/CMakeLists.txt	Wed Oct 22 10:40:29 2025 +0200
@@ -197,8 +197,11 @@
   ${REPROC_SOURCES}
 
   Sources/Dicomization/ActiveUploads.cpp
+  Sources/Dicomization/DicomizationJob.cpp
   Sources/Dicomization/ProcessRunner.cpp
+  Sources/Dicomization/SharedLogs.cpp
   Sources/Dicomization/TemporaryDirectory.cpp
+  Sources/Dicomization/WholeSlideImagingDicomizer.cpp
   Sources/EducationConfiguration.cpp
   Sources/EducationEnumerations.cpp
   Sources/EducationRestApi.cpp
@@ -221,6 +224,8 @@
 
 add_dependencies(OrthancEducation AutogeneratedTarget)
 
+set_target_properties(OrthancEducation PROPERTIES C_STANDARD 99)
+
 
 set_target_properties(OrthancEducation PROPERTIES
   VERSION ${ORTHANC_PLUGIN_VERSION}
--- a/Resources/CMake/Reproc.cmake	Wed Oct 22 10:24:53 2025 +0200
+++ b/Resources/CMake/Reproc.cmake	Wed Oct 22 10:40:29 2025 +0200
@@ -20,11 +20,32 @@
 
 
 set(REPROC_SOURCES_DIR ${CMAKE_BINARY_DIR}/reproc-14.2.5)
+
+if (IS_DIRECTORY "${REPROC_SOURCES_DIR}")
+  set(FirstRun OFF)
+else()
+  set(FirstRun ON)
+endif()
+
 DownloadPackage(
   "9ea81a0c1eef6b8f76463d41a86e8ddd"
   "https://orthanc.uclouvain.be/downloads/third-party-downloads/reproc-14.2.5.tar.gz"
   "${REPROC_SOURCES_DIR}")
 
+if (FirstRun)
+  # Apply the patches
+  execute_process(
+    COMMAND ${PATCH_EXECUTABLE} -p0 -N -i
+    ${CMAKE_CURRENT_LIST_DIR}/Reproc.patch
+    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+    RESULT_VARIABLE Failure
+    )
+
+  if (Failure)
+    message(FATAL_ERROR "Error while patching a file")
+  endif()
+endif()
+
 include_directories(${REPROC_SOURCES_DIR}/reproc/include/)
 
 set(REPROC_SOURCES
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Resources/CMake/Reproc.patch	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,43 @@
+diff -r -urEb reproc-14.2.5.orig/reproc/src/error.windows.c reproc-14.2.5/reproc/src/error.windows.c
+--- reproc-14.2.5.orig/reproc/src/error.windows.c	2025-10-21 22:17:59.386258877 +0200
++++ reproc-14.2.5/reproc/src/error.windows.c	2025-10-21 22:50:15.322957140 +0200
+@@ -8,8 +8,8 @@
+ 
+ #include <limits.h>
+ #include <stdlib.h>
+-#include <windows.h>
+ #include <winsock2.h>
++#include <windows.h>
+ 
+ #include <reproc/reproc.h>
+ 
+diff -r -urEb reproc-14.2.5.orig/reproc/src/macro.h reproc-14.2.5/reproc/src/macro.h
+--- reproc-14.2.5.orig/reproc/src/macro.h	2025-10-21 22:17:59.386258877 +0200
++++ reproc-14.2.5/reproc/src/macro.h	2025-10-21 22:37:53.283518598 +0200
+@@ -6,6 +6,13 @@
+ 
+ #if defined(_WIN32) && !defined(__MINGW32__)
+   #define THREAD_LOCAL __declspec(thread)
++#elif defined(__LSB_VERSION__)
++/**
++ * Linux Standard Base generates the following error if trying to use
++ * the thread local storage (TLS): "undefined reference to
++ * `__tls_get_addr'"
++ **/
++  #define THREAD_LOCAL
+ #else
+   #define THREAD_LOCAL __thread
+ #endif
+diff -r -urEb reproc-14.2.5.orig/reproc/src/pipe.windows.c reproc-14.2.5/reproc/src/pipe.windows.c
+--- reproc-14.2.5.orig/reproc/src/pipe.windows.c	2025-10-21 22:17:59.386258877 +0200
++++ reproc-14.2.5/reproc/src/pipe.windows.c	2025-10-21 22:50:04.247023847 +0200
+@@ -8,8 +8,8 @@
+ 
+ #include <limits.h>
+ #include <stdlib.h>
+-#include <windows.h>
+ #include <winsock2.h>
++#include <windows.h>
+ 
+ #include "error.h"
+ #include "handle.h"
--- a/Sources/Dicomization/ActiveUploads.cpp	Wed Oct 22 10:24:53 2025 +0200
+++ b/Sources/Dicomization/ActiveUploads.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -28,6 +28,8 @@
 
 #include <boost/iostreams/device/file_descriptor.hpp>
 #include <boost/iostreams/stream.hpp>
+#include <boost/filesystem.hpp>
+#include <fstream>
 
 
 static boost::posix_time::ptime GetNow()
@@ -45,7 +47,7 @@
   boost::posix_time::ptime                 lastUpdate_;
 
 public:
-  Upload(uint64_t fileSize) :
+  explicit Upload(uint64_t fileSize) :
     file_(new Orthanc::TemporaryFile),
     pos_(0),
     fileSize_(fileSize),
--- a/Sources/Dicomization/ActiveUploads.h	Wed Oct 22 10:24:53 2025 +0200
+++ b/Sources/Dicomization/ActiveUploads.h	Wed Oct 22 10:40:29 2025 +0200
@@ -27,6 +27,7 @@
 #include <TemporaryFile.h>
 
 #include <boost/thread/mutex.hpp>
+#include <map>
 
 
 class ActiveUploads : public boost::noncopyable
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/DicomizationJob.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,219 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "DicomizationJob.h"
+
+#include "ActiveUploads.h"
+
+#include <OrthancException.h>
+
+
+void DicomizationJob::Worker(DicomizationJob* that)
+{
+  assert(that != NULL);
+
+  std::unique_ptr<Orthanc::TemporaryFile> upload;
+
+  try
+  {
+    upload.reset(ActiveUploads::GetInstance().ReleaseTemporaryFile(that->uploadId_));
+  }
+  catch (Orthanc::OrthancException&)
+  {
+    boost::mutex::scoped_lock lock(that->mutex_);
+    that->status_ = Status_Failure;
+    return;
+  }
+
+  assert(upload.get() != NULL);
+
+  bool success;
+
+  try
+  {
+    success = that->dicomizer_->Execute(upload, that->logs_, that->stopped_);
+  }
+  catch (Orthanc::OrthancException& e)
+  {
+    success = false;
+  }
+  catch (...)
+  {
+    success = false;
+  }
+
+  {
+    boost::mutex::scoped_lock lock(that->mutex_);
+    that->status_ = (success ? Status_Success : Status_Failure);
+  }
+}
+
+
+DicomizationJob::DicomizationJob(const std::string& uploadId,
+                                 IDicomizer* dicomizer) :
+  uploadId_(uploadId),
+  dicomizer_(dicomizer),
+  stopped_(false),
+  status_(Status_Running)
+{
+  if (dicomizer == NULL)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
+  }
+
+  name_ = dicomizer->GetName();
+  jobType_ = dicomizer->GetJobType();
+}
+
+
+DicomizationJob::~DicomizationJob()
+{
+  if (thread_.joinable())
+  {
+    thread_.join();
+  }
+}
+
+
+void DicomizationJob::Start()
+{
+  thread_ = boost::thread(Worker, this);
+}
+
+
+Orthanc::JobStepResult DicomizationJob::Step(const std::string& jobId)
+{
+  boost::this_thread::sleep(boost::posix_time::milliseconds(100));
+
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    if (status_ == Status_Success ||
+        status_ == Status_Failure)
+    {
+      return (status_ == Status_Success ?
+              Orthanc::JobStepResult::Success() :
+              Orthanc::JobStepResult::Failure(Orthanc::ErrorCode_InternalError, ""));
+    }
+  }
+
+  return Orthanc::JobStepResult::Continue();
+}
+
+
+void DicomizationJob::Reset()
+{
+  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+}
+
+
+void DicomizationJob::Stop(Orthanc::JobStopReason reason)
+{
+  if (reason == Orthanc::JobStopReason_Canceled)
+  {
+    stopped_ = true;
+  }
+
+  if (thread_.joinable())
+  {
+    thread_.join();
+  }
+}
+
+
+void DicomizationJob::GetPublicContent(Json::Value& value) const
+{
+  std::string logs;
+  const_cast<SharedLogs&>(logs_).GetContent(logs);
+
+  value = Json::objectValue;
+  value["logs"] = logs;
+  value["name"] = name_;
+}
+
+
+bool DicomizationJob::GetOutput(std::string& output,
+                                Orthanc::MimeType& mime,
+                                std::string& filename,
+                                const std::string& key)
+{
+  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+}
+
+
+bool DicomizationJob::DeleteOutput(const std::string& key)
+{
+  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+}
+
+
+void DicomizationJob::DeleteAllOutputs()
+{
+  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+}
+
+
+bool DicomizationJob::GetUserData(Json::Value& userData) const
+{
+  return false;
+}
+
+
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+void DicomizationJob::SetUserData(const Json::Value& userData)
+{
+  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+}
+#endif
+
+
+
+static boost::mutex engineMutex_;
+static std::unique_ptr<Orthanc::JobsEngine> engine_;
+
+
+Orthanc::JobsEngine& DicomizationJob::GetJobsEngine()
+{
+  boost::mutex::scoped_lock lock(engineMutex_);
+
+  if (engine_.get() == NULL)
+  {
+    engine_.reset(new Orthanc::JobsEngine(20));  // Only keep 20 completed jobs
+    engine_->SetWorkersCount(1);
+    engine_->Start();
+  }
+
+  return *engine_;
+}
+
+
+void DicomizationJob::FinalizeJobsEngine()
+{
+  boost::mutex::scoped_lock lock(engineMutex_);
+
+  if (engine_.get() != NULL)
+  {
+    engine_->Stop();
+    engine_.reset(NULL);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/DicomizationJob.h	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,105 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "IDicomizer.h"
+
+#include <Compatibility.h>
+#include <JobsEngine/JobsEngine.h>
+
+
+class DicomizationJob : public Orthanc::IJob
+{
+private:
+  enum Status
+  {
+    Status_Running,
+    Status_Success,
+    Status_Failure
+  };
+
+  std::string                  uploadId_;
+  std::unique_ptr<IDicomizer>  dicomizer_;
+  std::string                  name_;
+  std::string                  jobType_;
+  SharedLogs                   logs_;
+  boost::thread                thread_;
+  bool                         stopped_;
+
+  boost::mutex                 mutex_;   // To protect "status_"
+  Status                       status_;
+
+  static void Worker(DicomizationJob* that);
+
+public:
+  DicomizationJob(const std::string& uploadId,
+                  IDicomizer* dicomizer);
+
+  virtual ~DicomizationJob();
+
+  virtual void Start() ORTHANC_OVERRIDE;
+
+  virtual Orthanc::JobStepResult Step(const std::string& jobId) ORTHANC_OVERRIDE;
+
+  virtual void Reset() ORTHANC_OVERRIDE;
+
+  virtual void Stop(Orthanc::JobStopReason reason) ORTHANC_OVERRIDE;
+
+  virtual float GetProgress() const ORTHANC_OVERRIDE
+  {
+    return 0;
+  }
+
+  virtual void GetJobType(std::string& target) const ORTHANC_OVERRIDE
+  {
+    target = jobType_;
+  }
+
+  virtual void GetPublicContent(Json::Value& value) const ORTHANC_OVERRIDE;
+
+  virtual bool Serialize(Json::Value& value) const ORTHANC_OVERRIDE
+  {
+    return false;
+  }
+
+  virtual bool GetOutput(std::string& output,
+                         Orthanc::MimeType& mime,
+                         std::string& filename,
+                         const std::string& key) ORTHANC_OVERRIDE;
+
+  virtual bool DeleteOutput(const std::string& key) ORTHANC_OVERRIDE;
+
+  virtual void DeleteAllOutputs() ORTHANC_OVERRIDE;
+
+  virtual bool GetUserData(Json::Value& userData) const ORTHANC_OVERRIDE;
+
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+  virtual void SetUserData(const Json::Value& userData) ORTHANC_OVERRIDE;
+#endif
+
+  static Orthanc::JobsEngine& GetJobsEngine();
+
+  static void FinalizeJobsEngine();
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/IDicomizer.h	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "SharedLogs.h"
+
+#include <Compatibility.h>
+#include <TemporaryFile.h>
+
+
+class IDicomizer : public boost::noncopyable
+{
+public:
+  virtual ~IDicomizer()
+  {
+  }
+
+  virtual std::string GetName() = 0;
+
+  virtual std::string GetJobType() = 0;
+
+  virtual bool Execute(std::unique_ptr<Orthanc::TemporaryFile>& upload,
+                       SharedLogs& logs,
+                       const bool& stopped) = 0;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/SharedLogs.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,39 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "SharedLogs.h"
+
+
+void SharedLogs::Append(const std::string& data)
+{
+  boost::mutex::scoped_lock lock(mutex_);
+  content_ += data;
+}
+
+
+void SharedLogs::GetContent(std::string& content)
+{
+  boost::mutex::scoped_lock lock(mutex_);
+  content = content_;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/SharedLogs.h	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,42 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include <boost/noncopyable.hpp>
+#include <boost/thread/mutex.hpp>
+#include <string>
+
+
+class SharedLogs : public boost::noncopyable
+{
+private:
+  boost::mutex   mutex_;
+  std::string    content_;
+
+public:
+  void Append(const std::string& data);
+
+  void GetContent(std::string& content);
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/WholeSlideImagingDicomizer.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,315 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "WholeSlideImagingDicomizer.h"
+
+#include "../EducationConfiguration.h"
+#include "ProcessRunner.h"
+
+#include <Compression/ZipReader.h>
+#include <OrthancException.h>
+#include <SystemToolbox.h>
+
+#include <boost/algorithm/string/predicate.hpp>
+#include <boost/thread.hpp>
+
+
+bool WholeSlideImagingDicomizer::Unzip(TemporaryDirectory& target,
+                                       std::string& unzipMaster,
+                                       const Orthanc::TemporaryFile& zip,
+                                       const bool& stopped)
+{
+  std::unique_ptr<Orthanc::ZipReader> reader(Orthanc::ZipReader::CreateFromFile(zip.GetPath()));
+
+  std::string filename, content;
+  while (reader->ReadNextFile(filename, content))
+  {
+    if (stopped)
+    {
+      return false;
+    }
+
+    // Ignore directories in the ZIP
+    if (!boost::ends_with(filename, "/"))
+    {
+      target.WriteFile(filename, content);
+
+      boost::filesystem::path path(target.GetPath(filename));
+
+      const std::string& extension = path.extension().string();
+
+      if (extension == ".mrxs" ||
+          extension == ".ndpi" ||
+          extension == ".scn" ||
+          extension == ".tif" ||
+          extension == ".tiff" ||
+          extension == ".png" ||
+          extension == ".jpg" ||
+          extension == ".jpeg")
+      {
+        if (unzipMaster.empty())
+        {
+          unzipMaster = path.string();
+        }
+        else
+        {
+          throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "ZIP file containing multiple candidate whole-slide images");
+        }
+      }
+    }
+  }
+
+  if (unzipMaster.empty())
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "ZIP file containing no whole-slide image");
+  }
+
+  return true;
+}
+
+
+void WholeSlideImagingDicomizer::PrepareArguments(std::list<std::string>& args) const
+{
+  if (reconstructPyramid_)
+  {
+    args.push_back("--pyramid");
+    args.push_back("1");
+  }
+
+  if (forceOpenSlide_)
+  {
+    args.push_back("--force-openslide");
+    args.push_back("1");
+  }
+
+  args.push_back("--color");
+
+  {
+    char color[32];
+    sprintf(color, "%d,%d,%d", backgroundRed_, backgroundGreen_, backgroundBlue_);
+    args.push_back(color);
+  }
+
+  const std::string openslide = EducationConfiguration::GetInstance().GetPathToOpenSlide();
+  if (!openslide.empty())
+  {
+    args.push_back("--openslide");
+    args.push_back(openslide);
+  }
+}
+
+
+bool WholeSlideImagingDicomizer::ExecuteDicomizer(const std::string& dicomizer,
+                                                  const std::list<std::string>& args,
+                                                  SharedLogs& logs,
+                                                  const bool& stopped)
+{
+  ProcessRunner runner;
+  runner.Start(dicomizer, args, ProcessRunner::Stream_Error);
+
+  while (runner.IsRunning())
+  {
+    if (stopped)
+    {
+      runner.Terminate();
+      return false;
+    }
+
+    std::string s;
+    runner.Read(s);
+    logs.Append(s);
+
+    boost::this_thread::sleep(boost::posix_time::milliseconds(100));
+  }
+
+  {
+    std::string s;
+    runner.Read(s);
+    logs.Append(s);
+  }
+
+  return (runner.GetExitCode() == 0);
+}
+
+
+bool WholeSlideImagingDicomizer::UploadDicomToOrthanc(const TemporaryDirectory& target,
+                                                      const bool& stopped)
+{
+  boost::filesystem::directory_iterator iterator(target.GetRoot());
+  boost::filesystem::directory_iterator end;
+
+  while (iterator != end)
+  {
+    if (stopped)
+    {
+      return false;
+    }
+
+    std::string content;
+
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+    Orthanc::SystemToolbox::ReadFile(content, iterator->path());
+#else
+    Orthanc::SystemToolbox::ReadFile(content, iterator->path().string());
+#endif
+
+    if (!content.empty())
+    {
+      Json::Value answer;
+      if (!OrthancPlugins::RestApiPost(answer, "/instances", content.c_str(), content.size(), false))
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "Cannot upload a DICOM-ized file");
+      }
+    }
+
+    ++iterator;
+  }
+
+  return true;
+}
+
+
+WholeSlideImagingDicomizer::WholeSlideImagingDicomizer() :
+  studyDescription_("Whole-slide image"),
+  backgroundRed_(255),
+  backgroundGreen_(255),
+  backgroundBlue_(255),
+  forceOpenSlide_(false),
+  reconstructPyramid_(true)
+{
+}
+
+
+void WholeSlideImagingDicomizer::SetBackgroundColor(uint8_t red,
+                                                    uint8_t green,
+                                                    uint8_t blue)
+{
+  backgroundRed_ = red;
+  backgroundGreen_ = green;
+  backgroundBlue_ = blue;
+}
+
+
+bool WholeSlideImagingDicomizer::Execute(std::unique_ptr<Orthanc::TemporaryFile>& upload,
+                                         SharedLogs& logs,
+                                         const bool& stopped)
+{
+  assert(upload.get() != NULL);
+
+  const std::string dicomizer = EducationConfiguration::GetInstance().GetPathToWsiDicomizer();
+  if (dicomizer.empty())
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "No DICOM-izer is configured for whole-slide images");
+  }
+
+  std::unique_ptr<TemporaryDirectory> unzip;
+  std::string unzipMaster;
+
+  if (IsZipFile(upload->GetPath()))
+  {
+    unzip.reset(new TemporaryDirectory);
+
+    if (!Unzip(*unzip, unzipMaster, *upload, stopped))
+    {
+      return false;
+    }
+
+    // We don't need the ZIP file anymore
+    upload.reset(NULL);
+  }
+
+  Orthanc::TemporaryFile dataset;
+
+  {
+    Json::Value json;
+    json["StudyDescription"] = studyDescription_;
+
+    std::string s;
+    Orthanc::Toolbox::WriteFastJson(s, json);
+    dataset.Write(s);
+  }
+
+  std::list<std::string> args;
+  PrepareArguments(args);
+
+  args.push_back("--dataset");
+
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+  args.push_back(dataset.GetPath().string());
+#else
+  args.push_back(dataset.GetPath());
+#endif
+
+  if (unzip.get() == NULL)
+  {
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+    args.push_back(upload->GetPath().string());
+#else
+    args.push_back(upload->GetPath());
+#endif
+  }
+  else
+  {
+    args.push_back(unzipMaster);
+  }
+
+  std::unique_ptr<TemporaryDirectory> target(new TemporaryDirectory);
+  args.push_back("--folder");
+  args.push_back(target->GetRoot().string());
+
+  if (!ExecuteDicomizer(dicomizer, args, logs, stopped))
+  {
+    return false;
+  }
+
+  unzip.reset(NULL);
+  upload.reset(NULL);
+
+  return UploadDicomToOrthanc(*target, stopped);
+}
+
+
+bool WholeSlideImagingDicomizer::IsZipFile(const boost::filesystem::path& path)
+{
+  std::string header;
+
+#if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 12, 10)
+  Orthanc::SystemToolbox::ReadFileRange(header, path, 0, 4, false /* don't throw exception */);
+#else
+  Orthanc::SystemToolbox::ReadFileRange(header, path.string(), 0, 4, false /* don't throw exception */);
+#endif
+
+  if (header.size() != 4)
+  {
+    return false;
+  }
+  else
+  {
+    // https://en.wikipedia.org/wiki/List_of_file_signatures
+    const uint8_t *b = reinterpret_cast<const uint8_t*>(header.c_str());
+    return ((b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x03 && b[3] == 0x04) ||
+            (b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x05 && b[3] == 0x06) ||
+            (b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x07 && b[3] == 0x08));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Sources/Dicomization/WholeSlideImagingDicomizer.h	Wed Oct 22 10:40:29 2025 +0200
@@ -0,0 +1,95 @@
+/**
+ * SPDX-FileCopyrightText: 2024-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Orthanc for Education
+ * Copyright (C) 2024-2025 Sebastien Jodogne, EPL UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "IDicomizer.h"
+#include "TemporaryDirectory.h"
+
+#include <list>
+
+
+class WholeSlideImagingDicomizer : public IDicomizer
+{
+private:
+  std::string  studyDescription_;
+  uint8_t      backgroundRed_;
+  uint8_t      backgroundGreen_;
+  uint8_t      backgroundBlue_;
+  bool         forceOpenSlide_;
+  bool         reconstructPyramid_;
+
+  static bool Unzip(TemporaryDirectory& target,
+                    std::string& unzipMaster,
+                    const Orthanc::TemporaryFile& zip,
+                    const bool& stopped);
+
+  void PrepareArguments(std::list<std::string>& args) const;
+
+  static bool ExecuteDicomizer(const std::string& dicomizer,
+                               const std::list<std::string>& args,
+                               SharedLogs& logs,
+                               const bool& stopped);
+
+  static bool UploadDicomToOrthanc(const TemporaryDirectory& target,
+                                   const bool& stopped);
+
+public:
+  WholeSlideImagingDicomizer();
+
+  void SetStudyDescription(const std::string& studyDescription)
+  {
+    studyDescription_ = studyDescription;
+  }
+
+  void SetBackgroundColor(uint8_t red,
+                          uint8_t green,
+                          uint8_t blue);
+
+  void SetForceOpenSlide(bool force)
+  {
+    forceOpenSlide_ = force;
+  }
+
+  void SetReconstructPyramid(bool reconstruct)
+  {
+    reconstructPyramid_ = reconstruct;
+  }
+
+  virtual std::string GetName() ORTHANC_OVERRIDE
+  {
+    return studyDescription_;
+  }
+
+  virtual std::string GetJobType() ORTHANC_OVERRIDE
+  {
+    return "wsi";
+  }
+
+  virtual bool Execute(std::unique_ptr<Orthanc::TemporaryFile>& upload,
+                       SharedLogs& logs,
+                       const bool& stopped) ORTHANC_OVERRIDE;
+
+  static bool IsZipFile(const boost::filesystem::path& path);
+};
--- a/Sources/EducationRestApi.cpp	Wed Oct 22 10:24:53 2025 +0200
+++ b/Sources/EducationRestApi.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -25,8 +25,8 @@
 #include "EducationRestApi.h"
 
 #include "Dicomization/ActiveUploads.h"
-#include "Dicomization/ProcessRunner.h"
-#include "Dicomization/TemporaryDirectory.h"
+#include "Dicomization/DicomizationJob.h"
+#include "Dicomization/WholeSlideImagingDicomizer.h"
 #include "EducationConfiguration.h"
 #include "LTI/LTIRoutes.h"
 #include "OrthancDatabase.h"
@@ -459,111 +459,113 @@
 
 
 
-
-class Thumbnail : public boost::noncopyable
+namespace
 {
-private:
-  const OrthancPlugins::OrthancImage&      source_;
-  std::unique_ptr<Orthanc::ImageAccessor>  modified_;
+  class Thumbnail : public boost::noncopyable
+  {
+  private:
+    const OrthancPlugins::OrthancImage&      source_;
+    std::unique_ptr<Orthanc::ImageAccessor>  modified_;
 
-public:
-  explicit Thumbnail(const OrthancPlugins::OrthancImage& source) :
-    source_(source)
-  {
-  }
+  public:
+    explicit Thumbnail(const OrthancPlugins::OrthancImage& source) :
+      source_(source)
+    {
+    }
 
-  Orthanc::PixelFormat GetFormat() const
-  {
-    if (modified_.get() == NULL)
+    Orthanc::PixelFormat GetFormat() const
     {
-      switch (source_.GetPixelFormat())
+      if (modified_.get() == NULL)
       {
-        case OrthancPluginPixelFormat_Grayscale8:
-          return Orthanc::PixelFormat_Grayscale8;
+        switch (source_.GetPixelFormat())
+        {
+          case OrthancPluginPixelFormat_Grayscale8:
+            return Orthanc::PixelFormat_Grayscale8;
+
+          case OrthancPluginPixelFormat_RGB24:
+            return Orthanc::PixelFormat_RGB24;
 
-        case OrthancPluginPixelFormat_RGB24:
-          return Orthanc::PixelFormat_RGB24;
-
-        default:
-          throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+          default:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
+        }
+      }
+      else
+      {
+        return modified_->GetFormat();
       }
     }
-    else
-    {
-      return modified_->GetFormat();
-    }
-  }
 
-  unsigned int GetWidth() const
-  {
-    if (modified_.get() == NULL)
-    {
-      return source_.GetWidth();
-    }
-    else
-    {
-      return modified_->GetWidth();
-    }
-  }
-
-  unsigned int GetHeight() const
-  {
-    if (modified_.get() == NULL)
-    {
-      return source_.GetHeight();
-    }
-    else
+    unsigned int GetWidth() const
     {
-      return modified_->GetHeight();
-    }
-  }
-
-  void GetAccessor(Orthanc::ImageAccessor& accessor) const
-  {
-    if (modified_.get() == NULL)
-    {
-      accessor.AssignReadOnly(GetFormat(), source_.GetWidth(), source_.GetHeight(), source_.GetPitch(), source_.GetBuffer());
-    }
-    else
-    {
-      modified_->GetReadOnlyAccessor(accessor);
+      if (modified_.get() == NULL)
+      {
+        return source_.GetWidth();
+      }
+      else
+      {
+        return modified_->GetWidth();
+      }
     }
-  }
 
-  void Resize(unsigned int width,
-              unsigned int height,
-              bool smooth)
-  {
-    Orthanc::ImageAccessor current;
-    GetAccessor(current);
-
-    std::unique_ptr<Orthanc::ImageAccessor> resized(new Orthanc::Image(current.GetFormat(), width, height, false));
-
-    if (smooth &&
-        width < current.GetWidth() &&
-        height < current.GetHeight())  // Only smooth if downscaling
+    unsigned int GetHeight() const
     {
       if (modified_.get() == NULL)
       {
-        std::unique_ptr<Orthanc::ImageAccessor> smoothed(Orthanc::Image::Clone(current));
-        Orthanc::ImageProcessing::SmoothGaussian5x5(*smoothed, false);
-        Orthanc::ImageProcessing::Resize(*resized, *smoothed);
+        return source_.GetHeight();
+      }
+      else
+      {
+        return modified_->GetHeight();
+      }
+    }
+
+    void GetAccessor(Orthanc::ImageAccessor& accessor) const
+    {
+      if (modified_.get() == NULL)
+      {
+        accessor.AssignReadOnly(GetFormat(), source_.GetWidth(), source_.GetHeight(), source_.GetPitch(), source_.GetBuffer());
       }
       else
       {
-        // The smoothing can be done inplace, as "resized" will overwrite "modified_"
-        Orthanc::ImageProcessing::SmoothGaussian5x5(*modified_, false);
-        Orthanc::ImageProcessing::Resize(*resized, *modified_);
+        modified_->GetReadOnlyAccessor(accessor);
       }
     }
-    else
+
+    void Resize(unsigned int width,
+                unsigned int height,
+                bool smooth)
     {
-      Orthanc::ImageProcessing::Resize(*resized, current);
+      Orthanc::ImageAccessor current;
+      GetAccessor(current);
+
+      std::unique_ptr<Orthanc::ImageAccessor> resized(new Orthanc::Image(current.GetFormat(), width, height, false));
+
+      if (smooth &&
+          width < current.GetWidth() &&
+          height < current.GetHeight())  // Only smooth if downscaling
+      {
+        if (modified_.get() == NULL)
+        {
+          std::unique_ptr<Orthanc::ImageAccessor> smoothed(Orthanc::Image::Clone(current));
+          Orthanc::ImageProcessing::SmoothGaussian5x5(*smoothed, false);
+          Orthanc::ImageProcessing::Resize(*resized, *smoothed);
+        }
+        else
+        {
+          // The smoothing can be done inplace, as "resized" will overwrite "modified_"
+          Orthanc::ImageProcessing::SmoothGaussian5x5(*modified_, false);
+          Orthanc::ImageProcessing::Resize(*resized, *modified_);
+        }
+      }
+      else
+      {
+        Orthanc::ImageProcessing::Resize(*resized, current);
+      }
+
+      modified_.reset(resized.release());
     }
-
-    modified_.reset(resized.release());
-  }
-};
+  };
+}
 
 
 
@@ -1239,547 +1241,24 @@
 }
 
 
-class SharedOutputBuffer : public boost::noncopyable
-{
-private:
-  boost::mutex   mutex_;
-  std::string    content_;
-
-public:
-  void Append(const std::string& data)
-  {
-    boost::mutex::scoped_lock lock(mutex_);
-    content_ += data;
-  }
-
-  void GetContent(std::string& content)
-  {
-    boost::mutex::scoped_lock lock(mutex_);
-    content  = content_;
-  }
-};
-
-
-class IDicomizer : public boost::noncopyable
-{
-public:
-  virtual ~IDicomizer()
-  {
-  }
-
-  virtual std::string GetName() = 0;
-
-  virtual std::string GetJobType() = 0;
-
-  virtual bool Execute(std::unique_ptr<Orthanc::TemporaryFile>& upload,
-                       SharedOutputBuffer& logs,
-                       const bool& stopped) = 0;
-};
-
-
-
-#include <Compression/ZipReader.h>
-
-
-static bool IsZipFile(const boost::filesystem::path& path)
-{
-  std::string header;
-  Orthanc::SystemToolbox::ReadFileRange(header, path, 0, 4, false /* don't throw exception */);
-
-  if (header.size() != 4)
-  {
-    return false;
-  }
-  else
-  {
-    // https://en.wikipedia.org/wiki/List_of_file_signatures
-    const uint8_t *b = reinterpret_cast<const uint8_t*>(header.c_str());
-    return ((b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x03 && b[3] == 0x04) ||
-            (b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x05 && b[3] == 0x06) ||
-            (b[0] == 0x50 && b[1] == 0x4b && b[2] == 0x07 && b[3] == 0x08));
-  }
-}
-
-
-class WholeSlideImagingDicomizer : public IDicomizer
-{
-private:
-  std::string  studyDescription_;
-  uint8_t      backgroundRed_;
-  uint8_t      backgroundGreen_;
-  uint8_t      backgroundBlue_;
-  bool         forceOpenSlide_;
-  bool         reconstructPyramid_;
-
-  static bool Unzip(TemporaryDirectory& target,
-                    std::string& unzipMaster,
-                    const Orthanc::TemporaryFile& zip,
-                    const bool& stopped)
-  {
-    std::unique_ptr<Orthanc::ZipReader> reader(Orthanc::ZipReader::CreateFromFile(zip.GetPath()));
-
-    std::string filename, content;
-    while (reader->ReadNextFile(filename, content))
-    {
-      if (stopped)
-      {
-        return false;
-      }
-
-      // Ignore directories in the ZIP
-      if (!boost::ends_with(filename, "/"))
-      {
-        target.WriteFile(filename, content);
-
-        boost::filesystem::path path(target.GetPath(filename));
-
-        const std::string& extension = path.extension().string();
-
-        if (extension == ".mrxs" ||
-            extension == ".ndpi" ||
-            extension == ".scn" ||
-            extension == ".tif" ||
-            extension == ".tiff" ||
-            extension == ".png" ||
-            extension == ".jpg" ||
-            extension == ".jpeg")
-        {
-          if (unzipMaster.empty())
-          {
-            unzipMaster = path.string();
-          }
-          else
-          {
-            throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "ZIP file containing multiple candidate whole-slide images");
-          }
-        }
-      }
-    }
-
-    if (unzipMaster.empty())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "ZIP file containing no whole-slide image");
-    }
-
-    return true;
-  }
-
-  void PrepareArguments(std::list<std::string>& args) const
-  {
-    if (reconstructPyramid_)
-    {
-      args.push_back("--pyramid");
-      args.push_back("1");
-    }
-
-    if (forceOpenSlide_)
-    {
-      args.push_back("--force-openslide");
-      args.push_back("1");
-    }
-
-    args.push_back("--color");
-
-    {
-      char color[32];
-      sprintf(color, "%d,%d,%d", backgroundRed_, backgroundGreen_, backgroundBlue_);
-      args.push_back(color);
-    }
-
-    const std::string openslide = EducationConfiguration::GetInstance().GetPathToOpenSlide();
-    if (!openslide.empty())
-    {
-      args.push_back("--openslide");
-      args.push_back(openslide);
-    }
-  }
-
-  static bool ExecuteDicomizer(const std::string& dicomizer,
-                               const std::list<std::string>& args,
-                               SharedOutputBuffer& logs,
-                               const bool& stopped)
-  {
-    ProcessRunner runner;
-    runner.Start(dicomizer, args, ProcessRunner::Stream_Error);
-
-    while (runner.IsRunning())
-    {
-      if (stopped)
-      {
-        runner.Terminate();
-        return false;
-      }
-
-      std::string s;
-      runner.Read(s);
-      logs.Append(s);
-
-      boost::this_thread::sleep(boost::posix_time::milliseconds(100));
-    }
-
-    {
-      std::string s;
-      runner.Read(s);
-      logs.Append(s);
-    }
-
-    return (runner.GetExitCode() == 0);
-  }
-
-  static bool UploadDicomToOrthanc(const TemporaryDirectory& target,
-                                   const bool& stopped)
-  {
-    boost::filesystem::directory_iterator iterator(target.GetRoot());
-    boost::filesystem::directory_iterator end;
-
-    while (iterator != end)
-    {
-      if (stopped)
-      {
-        return false;
-      }
-
-      std::string content;
-      Orthanc::SystemToolbox::ReadFile(content, iterator->path());
-
-      if (!content.empty())
-      {
-        Json::Value answer;
-        if (!OrthancPlugins::RestApiPost(answer, "/instances", content.c_str(), content.size(), false))
-        {
-          throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "Cannot upload a DICOM-ized file");
-        }
-      }
-
-      ++iterator;
-    }
-
-    return true;
-  }
-
-public:
-  WholeSlideImagingDicomizer() :
-    studyDescription_("Whole-slide image"),
-    backgroundRed_(255),
-    backgroundGreen_(255),
-    backgroundBlue_(255),
-    forceOpenSlide_(false),
-    reconstructPyramid_(true)
-  {
-  }
-
-  void SetStudyDescription(const std::string& studyDescription)
-  {
-    studyDescription_ = studyDescription;
-  }
-
-  void SetBackgroundColor(uint8_t red,
-                          uint8_t green,
-                          uint8_t blue)
-  {
-    backgroundRed_ = red;
-    backgroundGreen_ = green;
-    backgroundBlue_ = blue;
-  }
-
-  void SetForceOpenSlide(bool force)
-  {
-    forceOpenSlide_ = force;
-  }
-
-  void SetReconstructPyramid(bool reconstruct)
-  {
-    reconstructPyramid_ = reconstruct;
-  }
-
-  virtual std::string GetName() ORTHANC_OVERRIDE
-  {
-    return studyDescription_;
-  }
-
-  virtual std::string GetJobType() ORTHANC_OVERRIDE
-  {
-    return "wsi";
-  }
-
-  virtual bool Execute(std::unique_ptr<Orthanc::TemporaryFile>& upload,
-                       SharedOutputBuffer& logs,
-                       const bool& stopped) ORTHANC_OVERRIDE
-  {
-    assert(upload.get() != NULL);
-
-    const std::string dicomizer = EducationConfiguration::GetInstance().GetPathToWsiDicomizer();
-    if (dicomizer.empty())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "No DICOM-izer is configured for whole-slide images");
-    }
-
-    std::unique_ptr<TemporaryDirectory> unzip;
-    std::string unzipMaster;
-
-    if (IsZipFile(upload->GetPath()))
-    {
-      unzip.reset(new TemporaryDirectory);
-
-      if (!Unzip(*unzip, unzipMaster, *upload, stopped))
-      {
-        return false;
-      }
-
-      // We don't need the ZIP file anymore
-      upload.reset(NULL);
-    }
-
-    Orthanc::TemporaryFile dataset;
-
-    {
-      Json::Value json;
-      json["StudyDescription"] = studyDescription_;
-
-      std::string s;
-      Orthanc::Toolbox::WriteFastJson(s, json);
-      dataset.Write(s);
-    }
-
-    std::list<std::string> args;
-    PrepareArguments(args);
-
-    args.push_back("--dataset");
-    args.push_back(dataset.GetPath().string());
-
-    if (unzip.get() == NULL)
-    {
-      args.push_back(upload->GetPath().string());
-    }
-    else
-    {
-      args.push_back(unzipMaster);
-    }
-
-    std::unique_ptr<TemporaryDirectory> target(new TemporaryDirectory);
-    args.push_back("--folder");
-    args.push_back(target->GetRoot().string());
-
-    if (!ExecuteDicomizer(dicomizer, args, logs, stopped))
-    {
-      return false;
-    }
-
-    unzip.reset(NULL);
-    upload.reset(NULL);
-
-    return UploadDicomToOrthanc(*target, stopped);
-  }
-};
-
-
-
-#include <JobsEngine/JobsEngine.h>
-
-static Orthanc::JobsEngine engine_(20);  // Only keep 20 completed jobs
-
-
-class DicomizerJob : public Orthanc::IJob
-{
-private:
-  enum Status
-  {
-    Status_Running,
-    Status_Success,
-    Status_Failure
-  };
-
-  std::string                  uploadId_;
-  std::unique_ptr<IDicomizer>  dicomizer_;
-  std::string                  name_;
-  std::string                  jobType_;
-  SharedOutputBuffer           logs_;
-  boost::thread                thread_;
-  bool                         stopped_;
-
-  boost::mutex                 mutex_;   // To protect "status_"
-  Status                       status_;
-
-  static void Worker(DicomizerJob* that)
-  {
-    assert(that != NULL);
-
-    std::unique_ptr<Orthanc::TemporaryFile> upload;
-
-    try
-    {
-      upload.reset(ActiveUploads::GetInstance().ReleaseTemporaryFile(that->uploadId_));
-    }
-    catch (Orthanc::OrthancException&)
-    {
-      boost::mutex::scoped_lock lock(that->mutex_);
-      that->status_ = Status_Failure;
-      return;
-    }
-
-    assert(upload.get() != NULL);
-
-    bool success;
-
-    try
-    {
-      success = that->dicomizer_->Execute(upload, that->logs_, that->stopped_);
-    }
-    catch (Orthanc::OrthancException& e)
-    {
-      success = false;
-    }
-    catch (...)
-    {
-      success = false;
-    }
-
-    {
-      boost::mutex::scoped_lock lock(that->mutex_);
-      that->status_ = (success ? Status_Success : Status_Failure);
-    }
-  }
-
-public:
-  DicomizerJob(const std::string& uploadId,
-               IDicomizer* dicomizer) :
-    uploadId_(uploadId),
-    dicomizer_(dicomizer),
-    stopped_(false),
-    status_(Status_Running)
-  {
-    if (dicomizer == NULL)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
-    }
-
-    name_ = dicomizer->GetName();
-    jobType_ = dicomizer->GetJobType();
-  }
-
-  virtual ~DicomizerJob()
-  {
-    if (thread_.joinable())
-    {
-      thread_.join();
-    }
-  }
-
-  virtual void Start() ORTHANC_OVERRIDE
-  {
-    thread_ = boost::thread(Worker, this);
-  }
-
-  virtual Orthanc::JobStepResult Step(const std::string& jobId) ORTHANC_OVERRIDE
-  {
-    boost::this_thread::sleep(boost::posix_time::milliseconds(100));
-
-    {
-      boost::mutex::scoped_lock lock(mutex_);
-      if (status_ == Status_Success ||
-          status_ == Status_Failure)
-      {
-        return (status_ == Status_Success ?
-                Orthanc::JobStepResult::Success() :
-                Orthanc::JobStepResult::Failure(Orthanc::ErrorCode_InternalError, ""));
-      }
-    }
-
-    return Orthanc::JobStepResult::Continue();
-  }
-
-  virtual void Reset() ORTHANC_OVERRIDE
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
-  }
-
-  virtual void Stop(Orthanc::JobStopReason reason) ORTHANC_OVERRIDE
-  {
-    if (reason == Orthanc::JobStopReason_Canceled)
-    {
-      stopped_ = true;
-    }
-
-    if (thread_.joinable())
-    {
-      thread_.join();
-    }
-  }
-
-  virtual float GetProgress() const ORTHANC_OVERRIDE
-  {
-    return 0;
-  }
-
-  virtual void GetJobType(std::string& target) const ORTHANC_OVERRIDE
-  {
-    target = jobType_;
-  }
-
-  virtual void GetPublicContent(Json::Value& value) const ORTHANC_OVERRIDE
-  {
-    std::string logs;
-    const_cast<SharedOutputBuffer&>(logs_).GetContent(logs);
-
-    value = Json::objectValue;
-    value["logs"] = logs;
-    value["name"] = name_;
-  }
-
-  virtual bool Serialize(Json::Value& value) const ORTHANC_OVERRIDE
-  {
-    return false;
-  }
-
-  virtual bool GetOutput(std::string& output,
-                         Orthanc::MimeType& mime,
-                         std::string& filename,
-                         const std::string& key) ORTHANC_OVERRIDE
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
-  }
-
-  virtual bool DeleteOutput(const std::string& key) ORTHANC_OVERRIDE
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
-  }
-
-  virtual void DeleteAllOutputs() ORTHANC_OVERRIDE
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
-  }
-
-  virtual bool GetUserData(Json::Value& userData) const ORTHANC_OVERRIDE
-  {
-    return false;
-  }
-
-  virtual void SetUserData(const Json::Value& userData) ORTHANC_OVERRIDE
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
-  }
-};
-
-
-
-void Dicomization(OrthancPluginRestOutput* output,
-                  const std::string& url,
-                  const OrthancPluginHttpRequest* request,
-                  const AuthenticatedUser& user)
+void StartDicomization(OrthancPluginRestOutput* output,
+                       const std::string& url,
+                       const OrthancPluginHttpRequest* request,
+                       const AuthenticatedUser& user)
 {
   assert(user.GetRole() == Role_Administrator);
 
   if (request->method == OrthancPluginHttpMethod_Get)
   {
     std::set<std::string> jobs;
-    engine_.GetRegistry().ListJobs(jobs);
+    DicomizationJob::GetJobsEngine().GetRegistry().ListJobs(jobs);
 
     Json::Value answer = Json::arrayValue;
 
     for (std::set<std::string>::const_iterator it = jobs.begin(); it != jobs.end(); ++it)
     {
       Orthanc::JobInfo info;
-      if (engine_.GetRegistry().GetJobInfo(info, *it))
+      if (DicomizationJob::GetJobsEngine().GetRegistry().GetJobInfo(info, *it))
       {
         Json::Value item;
         item["id"] = *it;
@@ -1853,7 +1332,7 @@
         throw;
       }
 
-      engine_.GetRegistry().Submit(new DicomizerJob(uploadId, dicomizer.release()), 0 /* priority */);
+      DicomizationJob::GetJobsEngine().GetRegistry().Submit(new DicomizationJob(uploadId, dicomizer.release()), 0 /* priority */);
 
       HttpToolbox::AnswerText(output, "");
     }
@@ -1865,6 +1344,27 @@
 }
 
 
+void CancelDicomization(OrthancPluginRestOutput* output,
+                        const std::string& url,
+                        const OrthancPluginHttpRequest* request,
+                        const AuthenticatedUser& user)
+{
+  assert(user.GetRole() == Role_Administrator);
+
+  const std::string jobId(request->groups[0]);
+
+  if (request->method == OrthancPluginHttpMethod_Delete)
+  {
+    DicomizationJob::GetJobsEngine().GetRegistry().Cancel(jobId);
+    HttpToolbox::AnswerText(output, "");
+  }
+  else
+  {
+    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "DELETE");
+  }
+}
+
+
 void GetDicomizationLogs(OrthancPluginRestOutput* output,
                          const std::string& url,
                          const OrthancPluginHttpRequest* request,
@@ -1875,7 +1375,7 @@
   const std::string jobId(request->groups[0]);
 
   Orthanc::JobInfo info;
-  if (engine_.GetRegistry().GetJobInfo(info, jobId))
+  if (DicomizationJob::GetJobsEngine().GetRegistry().GetJobInfo(info, jobId))
   {
     std::string logs = Orthanc::SerializationToolbox::ReadString(info.GetStatus().GetPublicContent(), "logs");
     HttpToolbox::AnswerText(output, logs);
@@ -1887,9 +1387,6 @@
 }
 
 
-
-
-
 void RegisterEducationRestApiRoutes()
 {
   /**
@@ -1940,11 +1437,9 @@
   RestApiRouter::RegisterAdministratorRoute<SetLtiClientId>("/education/api/config/lti-client-id");
 
   RestApiRouter::RegisterAdministratorRoute<UploadFile>("/education/api/upload");
-  RestApiRouter::RegisterAdministratorRoute<Dicomization>("/education/api/dicomization");
+  RestApiRouter::RegisterAdministratorRoute<StartDicomization>("/education/api/dicomization");
+  RestApiRouter::RegisterAdministratorRoute<CancelDicomization>("/education/api/dicomization/{}");
   RestApiRouter::RegisterAdministratorGetRoute<GetDicomizationLogs>("/education/api/dicomization/{}/logs");
-
-  engine_.SetWorkersCount(1);
-  engine_.Start();
 }
 
 
@@ -1966,9 +1461,3 @@
 
   return NULL;
 }
-
-
-void FinalizeEducationJobsEngine()
-{
-  engine_.Stop();
-}
--- a/Sources/EducationRestApi.h	Wed Oct 22 10:24:53 2025 +0200
+++ b/Sources/EducationRestApi.h	Wed Oct 22 10:40:29 2025 +0200
@@ -31,6 +31,3 @@
 void RegisterEducationRestApiRoutes();
 
 AuthenticatedUser* AuthenticateFromEducationCookie(const std::list<HttpToolbox::Cookie>& cookies);
-
-void FinalizeEducationJobsEngine();
-
--- a/Sources/Plugin.cpp	Wed Oct 22 10:24:53 2025 +0200
+++ b/Sources/Plugin.cpp	Wed Oct 22 10:40:29 2025 +0200
@@ -23,6 +23,7 @@
 
 
 #include "Dicomization/ActiveUploads.h"
+#include "Dicomization/DicomizationJob.h"
 #include "EducationConfiguration.h"
 #include "EducationRestApi.h"
 #include "LTI/LTIRoutes.h"
@@ -402,7 +403,7 @@
 
 
 static boost::thread  uploadsCleanerThread_;
-static bool           uploadsCleanerContinue_;
+static bool           uploadsCleanerContinue_ = false;
 
 static void UploadsCleaner()
 {
@@ -475,10 +476,6 @@
       break;
     }
 
-    case OrthancPluginChangeType_OrthancStopped:
-      FinalizeEducationJobsEngine();
-      break;
-
     default:
       break;
     }
@@ -757,6 +754,8 @@
       uploadsCleanerThread_.join();
     }
 
+    DicomizationJob::FinalizeJobsEngine();
+
     Orthanc::Toolbox::FinalizeOpenSsl();
     Orthanc::Logging::Finalize();
   }
--- a/WebApplication/dashboard.html	Wed Oct 22 10:24:53 2025 +0200
+++ b/WebApplication/dashboard.html	Wed Oct 22 10:40:29 2025 +0200
@@ -103,7 +103,7 @@
                 </p>
                 <p>
                   This development was partially funded by the
-                  <a href="https://www.lespecialiste.be/fr/actualites/e-health/laquo-un-hopital-virtuel-pour-ameliorer-l-rsquo-enseignement-en-informatique-medical-raquo.html" target="_blank">Virtual Hospital grant</a> at
+                  <a href="https://www.virtual-hospital.org/" target="_blank">Virtual Hospital grant</a> at
                   <a href="https://www.uclouvain.be/facultes/epl" target="_blank">Louvain School of Engineering (EPL)</a>.
                   The platform is built on the top of <a href="https://orthanc.uclouvain.be/" target="_blank">Orthanc</a>, a free and open-source ecosystem
                   for medical imaging that is developed at UCLouvain.
@@ -461,6 +461,10 @@
                 <div class="row justify-content-center">
                   <div class="col-8">
 
+                    <p>
+                      <button type="button" class="btn btn-lg btn-primary" @click="reloadDicomizations()">Refresh</button>
+                    </p>
+
                     <table class="table align-middle">
                       <colgroup>
                         <col style="width: 1%;">
@@ -475,12 +479,12 @@
                           <th scope="col">Date</th>
                           <th scope="col">Type</th>
                           <th scope="col">Filename</th>
-                          <th scope="col">Logs</th>
+                          <th scope="col"></th>
                         </tr>
                       </thead>
                       <tbody>
                         <tr v-for="item in dicomizations">
-                          <td>
+                          <td class="text-nowrap">
                             <span class="badge bg-success" v-if="item.is_success">success</span>
                             <span class="badge bg-danger" v-if="item.is_failure">failure</span>
                             <span class="badge bg-secondary" v-if="!item.is_success && !item.is_failure">{{item.status.toLowerCase()}}</span>
@@ -488,8 +492,13 @@
                           <td class="text-nowrap">{{item.date}}</td>
                           <td>{{item.type}}</td>
                           <td>{{item.name}}</td>
-                          <td>
-                            <button class="btn btn-sm btn-primary ms-2">
+                          <td class="text-nowrap text-end">
+                            <button class="btn btn-sm btn-danger" v-if="!item.is_success && !item.is_failure"
+                                    data-bs-toggle="tooltip" data-bs-placement="top" title="Cancel the DICOM-ization">
+                              <i class="fa fa-trash" @click="cancelDicomization(item)"></i>
+                            </button>
+                            <button class="btn btn-sm btn-primary"
+                                    data-bs-toggle="tooltip" data-bs-placement="top" title="Open the DICOM-ization logs">
                               <i class="fa fa-file-text" @click="openLogs(item)"></i>
                             </button>
                           </td>
--- a/WebApplication/dashboard.js	Wed Oct 22 10:24:53 2025 +0200
+++ b/WebApplication/dashboard.js	Wed Oct 22 10:40:29 2025 +0200
@@ -140,7 +140,7 @@
 
     document.getElementById('pills-images-tab').addEventListener('shown.bs.tab', function (event) {
       that.reloadProjectsParameters();
-      that.projectIdForImages = '_no-project';
+      that.projectIdForImages = '';
       that.filter = '';
       that.selectedViewer = that.config.default_viewer;
     });
@@ -617,6 +617,14 @@
         });
     },
 
+    cancelDicomization: function(dicomization) {
+      var that = this;
+      axios.delete('../api/dicomization/' + dicomization.id)
+        .then(function() {
+          that.reloadDicomizations();
+        });
+    },
+
     openLogs: function(dicomization) {
       var url = '../api/dicomization/' + dicomization.id + '/logs';
       window.open(url, '_blank').focus();