changeset 4693:45bce660ce3a

added routes for bulk anonymization/modification
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 16 Jun 2021 16:44:04 +0200
parents e68edf92e5cc
children da1edb7d6332
files NEWS OrthancFramework/Sources/DicomParsing/DicomModification.cpp OrthancFramework/Sources/DicomParsing/DicomModification.h OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp OrthancServer/Sources/ServerJobs/ResourceModificationJob.h OrthancServer/UnitTestsSources/ServerJobsTests.cpp
diffstat 8 files changed, 526 insertions(+), 111 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Fri Jun 11 10:48:28 2021 +0200
+++ b/NEWS	Wed Jun 16 16:44:04 2021 +0200
@@ -11,6 +11,9 @@
 --------
 
 * API version upgraded to 13
+* New routes:
+  - "/tools/bulk-anonymize" to anonymize groups of multiple, unrelated resources at once
+  - "/tools/bulk-modify" to modify groups of multiple, unrelated resources at once
 * ZIP archive/media generated in synchronous mode are now streamed by default
 * "Replace" tags in "/modify" and "/anonymize" now supports value representation AT
 * "/jobs/..." has new field "ErrorDetails" to help identify the cause of an error
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Wed Jun 16 16:44:04 2021 +0200
@@ -155,10 +155,21 @@
         if (that_.uids_.find(tag) != that_.uids_.end() &&
             !IsManuallyModified(tag))
         {
-          // This is a first-level UID tag that must be anonymized
-          assert(vr == ValueRepresentation_UniqueIdentifier ||
-                 vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */);
-          newValue = that_.MapDicomIdentifier(value, ResourceType_Instance);
+          if (tag == DICOM_TAG_PATIENT_ID ||
+              tag == DICOM_TAG_PATIENT_NAME)
+          {
+            assert(vr == ValueRepresentation_LongString ||
+                   vr == ValueRepresentation_PersonName);
+            newValue = that_.MapDicomIdentifier(value, ResourceType_Patient);            
+          }
+          else
+          {
+            // This is a first-level UID tag that must be anonymized
+            assert(vr == ValueRepresentation_UniqueIdentifier ||
+                   vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */);
+            newValue = that_.MapDicomIdentifier(value, ResourceType_Instance);
+          }
+          
           return Action_Replace;
         }
         else
@@ -202,27 +213,35 @@
         }
         else if (that_.uids_.find(tag) != that_.uids_.end())
         {
-          assert(vr == ValueRepresentation_UniqueIdentifier ||
-                 vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */);
-
-          if (parentTags.size() == 2 &&
-              parentTags[0] == DICOM_TAG_REFERENCED_FRAME_OF_REFERENCE_SEQUENCE &&
-              parentTags[1] == DICOM_TAG_RT_REFERENCED_STUDY_SEQUENCE &&
-              tag == DICOM_TAG_REFERENCED_SOP_INSTANCE_UID)
+          if (tag == DICOM_TAG_PATIENT_ID ||
+              tag == DICOM_TAG_PATIENT_NAME)
           {
-            /**
-             * In RT-STRUCT, this ReferencedSOPInstanceUID is actually
-             * referencing a StudyInstanceUID !! (observed in many
-             * data sets including:
-             * https://wiki.cancerimagingarchive.net/display/Public/Lung+CT+Segmentation+Challenge+2017)
-             * Tested in "test_anonymize_relationships_5". Introduced
-             * in: https://hg.orthanc-server.com/orthanc/rev/3513
-             **/
-            newValue = that_.MapDicomIdentifier(value, ResourceType_Study);
+            newValue = that_.MapDicomIdentifier(value, ResourceType_Patient);
           }
           else
           {
-            newValue = that_.MapDicomIdentifier(value, ResourceType_Instance);
+            assert(vr == ValueRepresentation_UniqueIdentifier ||
+                   vr == ValueRepresentation_NotSupported /* for older versions of DCMTK */);
+
+            if (parentTags.size() == 2 &&
+                parentTags[0] == DICOM_TAG_REFERENCED_FRAME_OF_REFERENCE_SEQUENCE &&
+                parentTags[1] == DICOM_TAG_RT_REFERENCED_STUDY_SEQUENCE &&
+                tag == DICOM_TAG_REFERENCED_SOP_INSTANCE_UID)
+            {
+              /**
+               * In RT-STRUCT, this ReferencedSOPInstanceUID is actually
+               * referencing a StudyInstanceUID !! (observed in many
+               * data sets including:
+               * https://wiki.cancerimagingarchive.net/display/Public/Lung+CT+Segmentation+Challenge+2017)
+               * Tested in "test_anonymize_relationships_5". Introduced
+               * in: https://hg.orthanc-server.com/orthanc/rev/3513
+               **/
+              newValue = that_.MapDicomIdentifier(value, ResourceType_Study);
+            }
+            else
+            {
+              newValue = that_.MapDicomIdentifier(value, ResourceType_Instance);
+            }
           }
 
           return Action_Replace;
@@ -619,6 +638,10 @@
      **/
     uids_.clear();
 
+    // (*) "PatientID" and "PatientName" are handled as UIDs since Orthanc 1.9.4
+    uids_.insert(DICOM_TAG_PATIENT_ID);
+    uids_.insert(DICOM_TAG_PATIENT_NAME);
+    
     SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0008, 0x0014));  // Instance Creator UID                   <= from SetupAnonymization2008()
     SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0008, 0x1155));  // Referenced SOP Instance UID            <= from VisitString() + RemoveRelationships()
     SetupUidsFromOrthancInternal(uids_, removals_, DicomTag(0x0020, 0x0052));  // Frame of Reference UID                 <= from VisitString() + RemoveRelationships()
@@ -772,16 +795,29 @@
     ReplaceInternal(DicomTag(0x0012, 0x0062), "YES");
 
     // (*) Choose a random patient name and ID
-    std::string patientId = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Patient);
-    ReplaceInternal(DICOM_TAG_PATIENT_ID, patientId);
-    ReplaceInternal(DICOM_TAG_PATIENT_NAME, patientId);
+    uids_.insert(DICOM_TAG_PATIENT_ID);
+    uids_.insert(DICOM_TAG_PATIENT_NAME);
 
     // Sanity check
     for (SetOfTags::const_iterator it = uids_.begin(); it != uids_.end(); ++it)
     {
       ValueRepresentation vr = FromDcmtkBridge::LookupValueRepresentation(*it);
-      if (vr != ValueRepresentation_UniqueIdentifier &&
-          vr != ValueRepresentation_NotSupported /* for older versions of DCMTK */)
+      if (*it == DICOM_TAG_PATIENT_ID)
+      {
+        if (vr != ValueRepresentation_LongString)
+        {
+          throw OrthancException(ErrorCode_InternalError);
+        }
+      }
+      else if (*it == DICOM_TAG_PATIENT_NAME)
+      {
+        if (vr != ValueRepresentation_PersonName)
+        {
+          throw OrthancException(ErrorCode_InternalError);
+        }
+      }
+      else if (vr != ValueRepresentation_UniqueIdentifier &&
+               vr != ValueRepresentation_NotSupported /* for older versions of DCMTK */)
       {
         throw OrthancException(ErrorCode_InternalError);
       }
@@ -805,7 +841,10 @@
     
 
     // Sanity checks at the patient level
-    if (level_ == ResourceType_Patient && !IsReplaced(DICOM_TAG_PATIENT_ID))
+    bool isReplacedPatientId = (IsReplaced(DICOM_TAG_PATIENT_ID) ||
+                                uids_.find(DICOM_TAG_PATIENT_ID) != uids_.end());
+    
+    if (level_ == ResourceType_Patient && !isReplacedPatientId)
     {
       throw OrthancException(ErrorCode_BadRequest,
                              "When modifying a patient, her PatientID is required to be modified");
@@ -834,7 +873,7 @@
 
 
     // Sanity checks at the study level
-    if (level_ == ResourceType_Study && IsReplaced(DICOM_TAG_PATIENT_ID))
+    if (level_ == ResourceType_Study && isReplacedPatientId)
     {
       throw OrthancException(ErrorCode_BadRequest,
                              "When modifying a study, the parent PatientID cannot be manually modified");
@@ -857,7 +896,7 @@
 
 
     // Sanity checks at the series level
-    if (level_ == ResourceType_Series && IsReplaced(DICOM_TAG_PATIENT_ID))
+    if (level_ == ResourceType_Series && isReplacedPatientId)
     {
       throw OrthancException(ErrorCode_BadRequest,
                              "When modifying a series, the parent PatientID cannot be manually modified");
@@ -880,7 +919,7 @@
 
 
     // Sanity checks at the instance level
-    if (level_ == ResourceType_Instance && IsReplaced(DICOM_TAG_PATIENT_ID))
+    if (level_ == ResourceType_Instance && isReplacedPatientId)
     {
       throw OrthancException(ErrorCode_BadRequest,
                              "When modifying an instance, the parent PatientID cannot be manually modified");
@@ -1203,7 +1242,7 @@
   }
 
 
-  void DicomModification::ParseAnonymizationRequest(bool& patientNameReplaced,
+  void DicomModification::ParseAnonymizationRequest(bool& patientNameOverridden,
                                                     const Json::Value& request)
   {
     if (!request.isObject())
@@ -1230,8 +1269,6 @@
         
     SetupAnonymization(version);
 
-    std::string patientName = GetReplacementAsString(DICOM_TAG_PATIENT_NAME);    
-
     if (GetBooleanValue("KeepPrivateTags", request, false))
     {
       SetRemovePrivateTags(false);
@@ -1252,9 +1289,8 @@
       ParseListOfTags(*this, request["Keep"], TagOperation_Keep, force);
     }
 
-    patientNameReplaced = (IsReplaced(DICOM_TAG_PATIENT_NAME) &&
-                           GetReplacement(DICOM_TAG_PATIENT_NAME) == patientName);
-
+    patientNameOverridden = (uids_.find(DICOM_TAG_PATIENT_NAME) == uids_.end());
+    
     // New in Orthanc 1.6.0
     if (request.isMember("PrivateCreator"))
     {
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.h	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.h	Wed Jun 16 16:44:04 2021 +0200
@@ -235,7 +235,9 @@
 
     void ParseModifyRequest(const Json::Value& request);
 
-    void ParseAnonymizationRequest(bool& patientNameReplaced,
+    // "patientNameOverridden" is set to "true" iff. the PatientName
+    // (0010,0010) tag is manually replaced, removed, cleared or kept
+    void ParseAnonymizationRequest(bool& patientNameOverridden /* out */,
                                    const Json::Value& request);
 
     void SetDicomIdentifierGenerator(IDicomIdentifierGenerator& generator);
--- a/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp	Wed Jun 16 16:44:04 2021 +0200
@@ -2430,7 +2430,7 @@
         {
           Json::Value c = Json::objectValue;
           c["CodeValue"] = "122403";
-          c["0010,0010"] = "WORLD";  // Patient name
+          c["0008,103e"] = "WORLD";  // Series description
           b.append(c);
         }
 
@@ -2446,6 +2446,7 @@
   static const char* CODE_VALUE = "0008,0100";
   static const char* PATIENT_ID = "0010,0020";
   static const char* PATIENT_NAME = "0010,0010";
+  static const char* SERIES_DESCRIPTION = "0008,103e";
   static const char* PURPOSE_CODE_SEQ = "0040,a170";
   static const char* REF_IM_SEQ = "0008,1140";
   static const char* REF_SOP_CLASS = "0008,1150";
@@ -2525,7 +2526,7 @@
     Json::Value vv;
     dicom->DatasetToJson(vv, DicomToJsonFormat_Short, DicomToJsonFlags_None, 0);
 
-    ASSERT_EQ("WORLD", vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][PATIENT_NAME].asString());
+    ASSERT_EQ("WORLD", vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][SERIES_DESCRIPTION].asString());
     ASSERT_FALSE(vv[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0].isMember(CODE_VALUE));
   }
     
@@ -2685,7 +2686,7 @@
     ASSERT_NE("1.2.840.113619.2.176.2025.1499492.7040.1171286241.719", vv1[REF_IM_SEQ][0][REF_SOP_INSTANCE].asString());
     ASSERT_NE("1.2.840.113619.2.176.2025.1499492.7040.1171286241.726", vv1[REF_IM_SEQ][1][REF_SOP_INSTANCE].asString());
     ASSERT_NE("1.2.840.113704.1.111.7016.1342451220.40", vv1[REL_SERIES_SEQ][0][STUDY_INSTANCE_UID].asString());
-    ASSERT_EQ("WORLD", vv1[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][PATIENT_NAME].asString());
+    ASSERT_EQ("WORLD", vv1[REL_SERIES_SEQ][0][PURPOSE_CODE_SEQ][0][SERIES_DESCRIPTION].asString());
   }
 
   {
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Wed Jun 16 16:44:04 2021 +0200
@@ -146,12 +146,12 @@
     if (call.ParseJsonRequest(request) &&
         request.isObject())
     {
-      bool patientNameReplaced;
-      target.ParseAnonymizationRequest(patientNameReplaced, request);
+      bool patientNameOverridden;
+      target.ParseAnonymizationRequest(patientNameOverridden, request);
 
-      if (patientNameReplaced)
+      if (!patientNameOverridden)
       {
-        // Overwrite the random Patient's Name by one that is more
+        // Override the random Patient's Name by one that is more
         // user-friendly (provided none was specified by the user)
         target.Replace(DICOM_TAG_PATIENT_NAME, GeneratePatientName(OrthancRestApi::GetContext(call)), true);
       }
@@ -209,6 +209,27 @@
   }
 
 
+  static ResourceType DetectModifyLevel(const DicomModification& modification)
+  {
+    if (modification.IsReplaced(DICOM_TAG_PATIENT_ID))
+    {
+      return ResourceType_Patient;
+    }
+    else if (modification.IsReplaced(DICOM_TAG_STUDY_INSTANCE_UID))
+    {
+      return ResourceType_Study;
+    }
+    else if (modification.IsReplaced(DICOM_TAG_SERIES_INSTANCE_UID))
+    {
+      return ResourceType_Series;
+    }
+    else
+    {
+      return ResourceType_Instance;
+    }
+  }
+
+
   static void ModifyInstance(RestApiPostCall& call)
   {
     if (call.IsDocumentation())
@@ -230,22 +251,7 @@
     Json::Value request;
     ParseModifyRequest(request, modification, call);
 
-    if (modification.IsReplaced(DICOM_TAG_PATIENT_ID))
-    {
-      modification.SetLevel(ResourceType_Patient);
-    }
-    else if (modification.IsReplaced(DICOM_TAG_STUDY_INSTANCE_UID))
-    {
-      modification.SetLevel(ResourceType_Study);
-    }
-    else if (modification.IsReplaced(DICOM_TAG_SERIES_INSTANCE_UID))
-    {
-      modification.SetLevel(ResourceType_Series);
-    }
-    else
-    {
-      modification.SetLevel(ResourceType_Instance);
-    }
+    modification.SetLevel(DetectModifyLevel(modification));
 
     static const char* TRANSCODE = "Transcode";
     if (request.isMember(TRANSCODE))
@@ -311,13 +317,23 @@
                                     bool isAnonymization,
                                     RestApiPostCall& call,
                                     const Json::Value& body,
-                                    ResourceType level)
+                                    ResourceType outputLevel /* unused for multiple resources */,
+                                    bool isSingleResource,
+                                    const std::set<std::string>& resources)
   {
     ServerContext& context = OrthancRestApi::GetContext(call);
 
     std::unique_ptr<ResourceModificationJob> job(new ResourceModificationJob(context));
 
-    job->SetModification(modification.release(), level, isAnonymization);
+    if (isSingleResource)  // This notably configures the output format
+    {
+      job->SetSingleResourceModification(modification.release(), outputLevel, isAnonymization);
+    }
+    else
+    {
+      job->SetMultipleResourcesModification(modification.release(), isAnonymization);
+    }
+    
     job->SetOrigin(call);
     SetKeepSource(*job, body);
 
@@ -326,8 +342,13 @@
     {
       job->SetTranscode(SerializationToolbox::ReadString(body, TRANSCODE));
     }
+
+    for (std::set<std::string>::const_iterator
+           it = resources.begin(); it != resources.end(); ++it)
+    {
+      context.AddChildInstances(*job, *it);
+    }
     
-    context.AddChildInstances(*job, call.GetUriComponent("id", ""));
     job->AddTrailingStep();
 
     OrthancRestApi::GetApi(call).SubmitCommandsJob
@@ -335,6 +356,35 @@
   }
 
 
+  static void SubmitModificationJob(std::unique_ptr<DicomModification>& modification,
+                                    bool isAnonymization,
+                                    RestApiPostCall& call,
+                                    const Json::Value& body,
+                                    ResourceType outputLevel)
+  {
+    // This was the only flavor in Orthanc <= 1.9.3
+    std::set<std::string> resources;
+    resources.insert(call.GetUriComponent("id", ""));
+    
+    SubmitModificationJob(modification, isAnonymization, call, body, outputLevel,
+                          true /* single resource */, resources);
+  }
+
+  
+  static void SubmitBulkJob(std::unique_ptr<DicomModification>& modification,
+                            bool isAnonymization,
+                            RestApiPostCall& call,
+                            const Json::Value& body)
+  {
+    std::set<std::string> resources;
+    SerializationToolbox::ReadSetOfStrings(resources, body, "Resources");
+
+    SubmitModificationJob(modification, isAnonymization,
+                          call, body, ResourceType_Instance /* arbitrary value, unused */,
+                          false /* multiple resources */, resources);
+  }
+
+
   template <enum ResourceType resourceType>
   static void ModifyResource(RestApiPostCall& call)
   {
@@ -360,12 +410,41 @@
     ParseModifyRequest(body, *modification, call);
 
     modification->SetLevel(resourceType);
-
+    
     SubmitModificationJob(modification, false /* not an anonymization */,
                           call, body, resourceType);
   }
 
 
+  // New in Orthanc 1.9.4
+  static void BulkModify(RestApiPostCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      OrthancRestApi::DocumentSubmitCommandsJob(call);
+      DocumentModifyOptions(call);
+      call.GetDocumentation()
+        .SetTag("System")
+        .SetSummary("Modify a set of instances")
+        .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", false)
+        .SetDescription("Start a job that will modify all the DICOM patients, studies, series or instances "
+                        "whose identifiers are provided in the `Resources` field.")
+        .AddAnswerType(MimeType_Json, "The list of all the resources that have been altered by this modification");
+      return;
+    }
+    
+    std::unique_ptr<DicomModification> modification(new DicomModification);
+
+    Json::Value body;
+    ParseModifyRequest(body, *modification, call);
+
+    modification->SetLevel(DetectModifyLevel(*modification));
+
+    SubmitBulkJob(modification, false /* not an anonymization */, call, body);
+  }
+
+
   template <enum ResourceType resourceType>
   static void AnonymizeResource(RestApiPostCall& call)
   {
@@ -395,6 +474,33 @@
   }
 
 
+  // New in Orthanc 1.9.4
+  static void BulkAnonymize(RestApiPostCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      OrthancRestApi::DocumentSubmitCommandsJob(call);
+      DocumentAnonymizationOptions(call);
+      call.GetDocumentation()
+        .SetTag("System")
+        .SetSummary("Anonymize a set of instances")
+        .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", false)
+        .SetDescription("Start a job that will anonymize all the DICOM patients, studies, series or instances "
+                        "whose identifiers are provided in the `Resources` field.")
+        .AddAnswerType(MimeType_Json, "The list of all the resources that have been created by this anonymization");
+      return;
+    }
+
+    std::unique_ptr<DicomModification> modification(new DicomModification);
+
+    Json::Value body;
+    ParseAnonymizationRequest(body, *modification, call);
+
+    SubmitBulkJob(modification, true /* anonymization */, call, body);
+  }
+
+
   static void StoreCreatedInstance(std::string& id /* out */,
                                    RestApiPostCall& call,
                                    ParsedDicomFile& dicom,
@@ -1054,11 +1160,13 @@
     Register("/series/{id}/modify", ModifyResource<ResourceType_Series>);
     Register("/studies/{id}/modify", ModifyResource<ResourceType_Study>);
     Register("/patients/{id}/modify", ModifyResource<ResourceType_Patient>);
+    Register("/tools/bulk-modify", BulkModify);
 
     Register("/instances/{id}/anonymize", AnonymizeInstance);
     Register("/series/{id}/anonymize", AnonymizeResource<ResourceType_Series>);
     Register("/studies/{id}/anonymize", AnonymizeResource<ResourceType_Study>);
     Register("/patients/{id}/anonymize", AnonymizeResource<ResourceType_Patient>);
+    Register("/tools/bulk-anonymize", BulkAnonymize);
 
     Register("/tools/create-dicom", CreateDicom);
 
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Wed Jun 16 16:44:04 2021 +0200
@@ -44,7 +44,16 @@
 
 namespace Orthanc
 {
-  class ResourceModificationJob::Output : public boost::noncopyable
+  static void FormatResource(Json::Value& target,
+                             ResourceType level,
+                             const std::string& id)
+  {
+    target["Type"] = EnumerationToString(level);
+    target["ID"] = id;
+    target["Path"] = GetBasePath(level, id);
+  }
+  
+  class ResourceModificationJob::SingleOutput : public IOutput
   {
   private:
     ResourceType  level_;
@@ -53,7 +62,7 @@
     std::string   patientId_;
 
   public:
-    explicit Output(ResourceType level) :
+    explicit SingleOutput(ResourceType level) :
       level_(level),
       isFirst_(true)
     {
@@ -65,13 +74,7 @@
       }            
     }
 
-    ResourceType GetLevel() const
-    {
-      return level_;
-    }
-    
-
-    void Update(DicomInstanceHasher& hasher)
+    virtual void Update(DicomInstanceHasher& hasher) ORTHANC_OVERRIDE
     {
       if (isFirst_)
       {
@@ -98,40 +101,78 @@
       }
     }
 
+    virtual void Format(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      assert(target.type() == Json::objectValue);
 
-    bool Format(Json::Value& target)
-    {
-      if (isFirst_)
+      if (!isFirst_)
       {
-        return false;
-      }
-      else
-      {
-        target = Json::objectValue;
-        target["Type"] = EnumerationToString(level_);
-        target["ID"] = id_;
-        target["Path"] = GetBasePath(level_, id_);
+        FormatResource(target, level_, id_);
         target["PatientID"] = patientId_;
-        return true;
       }
     }
 
-  
-    bool GetIdentifier(std::string& id)
+    virtual bool IsSingleResource() const ORTHANC_OVERRIDE
     {
-      if (isFirst_)
-      {
-        return false;
-      }
-      else
-      {
-        id = id_;
-        return true;
-      }
+      return true;
+    }
+
+    ResourceType GetLevel() const
+    {
+      return level_;
     }
   };
     
 
+  class ResourceModificationJob::MultipleOutputs : public IOutput
+  {
+  private:
+    static void FormatResources(Json::Value& target,
+                                ResourceType level,
+                                const std::set<std::string>& resources)
+    {
+      assert(target.type() == Json::arrayValue);
+
+      for (std::set<std::string>::const_iterator
+             it = resources.begin(); it != resources.end(); ++it)
+      {
+        Json::Value item = Json::objectValue;
+        FormatResource(item, level, *it);
+        target.append(item);        
+      }
+    }
+    
+    std::set<std::string>  instances_;
+    std::set<std::string>  series_;
+    std::set<std::string>  studies_;
+    std::set<std::string>  patients_;
+
+  public:
+    virtual void Update(DicomInstanceHasher& hasher) ORTHANC_OVERRIDE
+    {
+      instances_.insert(hasher.HashInstance());
+      series_.insert(hasher.HashSeries());
+      studies_.insert(hasher.HashStudy());
+      patients_.insert(hasher.HashPatient());
+    }
+
+    virtual void Format(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      assert(target.type() == Json::objectValue);
+      Json::Value resources = Json::arrayValue;
+      FormatResources(resources, ResourceType_Instance, instances_);
+      FormatResources(resources, ResourceType_Series, series_);
+      FormatResources(resources, ResourceType_Study, studies_);
+      FormatResources(resources, ResourceType_Patient, patients_);
+      target["Resources"] = resources;
+    }
+
+    virtual bool IsSingleResource() const ORTHANC_OVERRIDE
+    {
+      return false;
+    }
+  };
+    
 
 
   bool ResourceModificationJob::HandleInstance(const std::string& instance)
@@ -271,7 +312,6 @@
 
   ResourceModificationJob::ResourceModificationJob(ServerContext& context) :
     CleaningInstancesJob(context, true /* by default, keep source */),
-    modification_(new DicomModification),
     isAnonymization_(false),
     transcode_(false),
     transferSyntax_(DicomTransferSyntax_LittleEndianExplicit)  // dummy initialization
@@ -279,9 +319,9 @@
   }
 
 
-  void ResourceModificationJob::SetModification(DicomModification* modification,
-                                                ResourceType level,
-                                                bool isAnonymization)
+  void ResourceModificationJob::SetSingleResourceModification(DicomModification* modification,
+                                                              ResourceType outputLevel,
+                                                              bool isAnonymization)
   {
     if (modification == NULL)
     {
@@ -294,7 +334,27 @@
     else
     {
       modification_.reset(modification);
-      output_.reset(new Output(level));
+      output_.reset(new SingleOutput(outputLevel));
+      isAnonymization_ = isAnonymization;
+    }
+  }
+
+
+  void ResourceModificationJob::SetMultipleResourcesModification(DicomModification* modification,
+                                                                 bool isAnonymization)
+  {
+    if (modification == NULL)
+    {
+      throw OrthancException(ErrorCode_NullPointer);
+    }
+    else if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      modification_.reset(modification);
+      output_.reset(new MultipleOutputs);
       isAnonymization_ = isAnonymization;
     }
   }
@@ -387,6 +447,37 @@
   }
 
 
+  bool ResourceModificationJob::IsSingleResourceModification() const
+  {
+    if (modification_.get() == NULL)
+    {
+      assert(output_.get() == NULL);
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      assert(output_.get() != NULL);
+      return output_->IsSingleResource();
+    }
+  }
+  
+
+  ResourceType ResourceModificationJob::GetOutputLevel() const
+  {
+    if (IsSingleResourceModification())
+    {
+      assert(modification_.get() != NULL &&
+             output_.get() != NULL);
+      return dynamic_cast<const SingleOutput&>(*output_).GetLevel();
+    }
+    else
+    {
+      // Not applicable if multiple resources
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+  }
+
+
   void ResourceModificationJob::GetPublicContent(Json::Value& value)
   {
     CleaningInstancesJob::GetPublicContent(value);
@@ -409,6 +500,8 @@
   static const char* ORIGIN = "Origin";
   static const char* IS_ANONYMIZATION = "IsAnonymization";
   static const char* TRANSCODE = "Transcode";
+  static const char* OUTPUT_LEVEL = "OutputLevel";
+  static const char* IS_SINGLE_RESOURCE = "IsSingleResource";
   
 
   ResourceModificationJob::ResourceModificationJob(ServerContext& context,
@@ -418,9 +511,7 @@
   {
     assert(serialized.type() == Json::objectValue);
 
-    isAnonymization_ = SerializationToolbox::ReadBoolean(serialized, IS_ANONYMIZATION);
     origin_ = DicomInstanceOrigin(serialized[ORIGIN]);
-    modification_.reset(new DicomModification(serialized[MODIFICATION]));
 
     if (serialized.isMember(TRANSCODE))
     {
@@ -430,11 +521,62 @@
     {
       transcode_ = false;
     }
+
+    bool isSingleResource;
+    if (serialized.isMember(IS_SINGLE_RESOURCE))
+    {
+      isSingleResource = SerializationToolbox::ReadBoolean(serialized, IS_SINGLE_RESOURCE);
+    }
+    else
+    {
+      isSingleResource = true;  // Backward compatibility with Orthanc <= 1.9.3
+    }
+
+    bool isAnonymization = SerializationToolbox::ReadBoolean(serialized, IS_ANONYMIZATION);
+    std::unique_ptr<DicomModification> modification(new DicomModification(serialized[MODIFICATION]));
+
+    if (isSingleResource)
+    {
+      ResourceType outputLevel;
+      
+      if (serialized.isMember(OUTPUT_LEVEL))
+      {
+        // New in Orthanc 1.9.4. This fixes an *incorrect* behavior in
+        // Orthanc <= 1.9.3, in which "outputLevel" would be set to
+        // "modification->GetLevel()"
+        outputLevel = StringToResourceType(SerializationToolbox::ReadString(serialized, OUTPUT_LEVEL).c_str());
+      }
+      else
+      {
+        // Use the buggy convention from Orthanc <= 1.9.3 (which is
+        // the only thing we have at hand)
+        outputLevel = modification->GetLevel();
+
+        if (outputLevel == ResourceType_Instance)
+        {
+          // This should never happen, but as "SingleOutput" doesn't
+          // support instance-level anonymization, don't take any risk
+          // and choose an arbitrary output level
+          outputLevel = ResourceType_Patient;
+        }
+      }
+      
+      SetSingleResourceModification(modification.release(), outputLevel, isAnonymization);
+    }
+    else
+    {
+      // New in Orthanc 1.9.4
+      SetMultipleResourcesModification(modification.release(), isAnonymization);
+    }
   }
   
   bool ResourceModificationJob::Serialize(Json::Value& value)
   {
-    if (!CleaningInstancesJob::Serialize(value))
+    if (modification_.get() == NULL)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else if (!CleaningInstancesJob::Serialize(value))
     {
       return false;
     }
@@ -455,6 +597,13 @@
       modification_->Serialize(tmp);
       value[MODIFICATION] = tmp;
 
+      // New in Orthanc 1.9.4
+      value[IS_SINGLE_RESOURCE] = IsSingleResourceModification();
+      if (IsSingleResourceModification())
+      {
+        value[OUTPUT_LEVEL] = EnumerationToString(GetOutputLevel());
+      }
+      
       return true;
     }
   }
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.h	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.h	Wed Jun 16 16:44:04 2021 +0200
@@ -44,10 +44,25 @@
   class ResourceModificationJob : public CleaningInstancesJob
   {
   private:
-    class Output;
+    class IOutput : public boost::noncopyable
+    {
+    public:
+      virtual ~IOutput()
+      {
+      }
+
+      virtual void Update(DicomInstanceHasher& hasher) = 0;
+
+      virtual void Format(Json::Value& target) const = 0;
+
+      virtual bool IsSingleResource() const = 0;
+    };
+    
+    class SingleOutput;
+    class MultipleOutputs;
     
     std::unique_ptr<DicomModification>  modification_;
-    boost::shared_ptr<Output>           output_;
+    boost::shared_ptr<IOutput>          output_;
     bool                                isAnonymization_;
     DicomInstanceOrigin                 origin_;
     bool                                transcode_;
@@ -62,9 +77,14 @@
     ResourceModificationJob(ServerContext& context,
                             const Json::Value& serialized);
 
-    void SetModification(DicomModification* modification,   // Takes ownership
-                         ResourceType level,
-                         bool isAnonymization);
+    // NB: The "outputLevel" only controls the output format, and
+    // might *not* be the same as "modification->GetLevel()"
+    void SetSingleResourceModification(DicomModification* modification,   // Takes ownership
+                                       ResourceType outputLevel,
+                                       bool isAnonymization);
+
+    void SetMultipleResourcesModification(DicomModification* modification,   // Takes ownership
+                                          bool isAnonymization);
 
     void SetOrigin(const DicomInstanceOrigin& origin);
 
@@ -95,6 +115,11 @@
 
     void ClearTranscode();
 
+    bool IsSingleResourceModification() const;
+
+    // Only possible if "IsSingleResourceModification()"
+    ResourceType GetOutputLevel() const;
+
     virtual void Stop(JobStopReason reason) ORTHANC_OVERRIDE
     {
     }
--- a/OrthancServer/UnitTestsSources/ServerJobsTests.cpp	Fri Jun 11 10:48:28 2021 +0200
+++ b/OrthancServer/UnitTestsSources/ServerJobsTests.cpp	Wed Jun 16 16:44:04 2021 +0200
@@ -819,11 +819,16 @@
 
   {
     std::unique_ptr<DicomModification> modification(new DicomModification);
-    modification->SetupAnonymization(DicomVersion_2008);    
+    modification->SetupAnonymization(DicomVersion_2008);
+    modification->SetLevel(ResourceType_Series);
 
     ResourceModificationJob job(GetContext());
-    job.SetModification(modification.release(), ResourceType_Patient, true);
+    ASSERT_THROW(job.IsSingleResourceModification(), OrthancException);
+    job.SetSingleResourceModification(modification.release(), ResourceType_Patient, true);
     job.SetOrigin(DicomInstanceOrigin::FromLua());
+    ASSERT_TRUE(job.IsAnonymization());
+    ASSERT_TRUE(job.IsSingleResourceModification());
+    ASSERT_EQ(ResourceType_Patient, job.GetOutputLevel());
 
     job.AddTrailingStep();  // Necessary since 1.7.0
     ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job));
@@ -840,12 +845,32 @@
     ASSERT_THROW(tmp.GetTransferSyntax(), OrthancException);
     ASSERT_EQ(RequestOrigin_Lua, tmp.GetOrigin().GetRequestOrigin());
     ASSERT_TRUE(tmp.GetModification().IsRemoved(DICOM_TAG_STUDY_DESCRIPTION));
+    ASSERT_TRUE(tmp.IsSingleResourceModification());
+    ASSERT_EQ(ResourceType_Patient, tmp.GetOutputLevel());
+    ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel());
+  }
+
+  {
+    // Backward compatibility with Orthanc 1.9.3
+    ASSERT_TRUE(s.isMember("OutputLevel"));
+    ASSERT_TRUE(s.isMember("IsSingleResource"));
+    s.removeMember("OutputLevel");
+    s.removeMember("IsSingleResource");
+    
+    std::unique_ptr<IJob> job;
+    job.reset(unserializer.UnserializeJob(s));
+
+    ResourceModificationJob& tmp = dynamic_cast<ResourceModificationJob&>(*job);
+    ASSERT_TRUE(tmp.IsSingleResourceModification());
+    ASSERT_EQ(ResourceType_Series, tmp.GetOutputLevel());  // old, incorrect behavior
+    ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel());
   }
 
   {
     ResourceModificationJob job(GetContext());
     ASSERT_THROW(job.SetTranscode("nope"), OrthancException);
     job.SetTranscode(DicomTransferSyntax_JPEGProcess1);
+    job.SetSingleResourceModification(new DicomModification, ResourceType_Study, false);
 
     job.AddTrailingStep();  // Necessary since 1.7.0
     ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job));
@@ -861,6 +886,72 @@
     ASSERT_TRUE(tmp.IsTranscode());
     ASSERT_EQ(DicomTransferSyntax_JPEGProcess1, tmp.GetTransferSyntax());
     ASSERT_EQ(RequestOrigin_Unknown, tmp.GetOrigin().GetRequestOrigin());
+    ASSERT_TRUE(tmp.IsSingleResourceModification());
+    ASSERT_EQ(ResourceType_Study, tmp.GetOutputLevel());
+    ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel());
+  }
+
+  {
+    ResourceModificationJob job(GetContext());
+    job.SetMultipleResourcesModification(new DicomModification, true);
+    job.AddInstance("toto");
+    job.AddInstance("tutu");
+    job.AddTrailingStep();  // Necessary since 1.7.0
+    ASSERT_TRUE(CheckIdempotentSetOfInstances(unserializer, job));
+    ASSERT_TRUE(job.Serialize(s));
+  }
+
+  {
+    std::unique_ptr<IJob> job;
+    job.reset(unserializer.UnserializeJob(s));
+
+    ResourceModificationJob& tmp = dynamic_cast<ResourceModificationJob&>(*job);
+
+    std::set<std::string> instances;
+    for (size_t i = 0; i < tmp.GetInstancesCount(); i++)
+    {
+      instances.insert(tmp.GetInstance(i));
+    }
+    
+    ASSERT_EQ(2u, instances.size());
+    ASSERT_TRUE(instances.find("toto") != instances.end());
+    ASSERT_TRUE(instances.find("tutu") != instances.end());
+
+    ASSERT_TRUE(tmp.IsAnonymization());
+    ASSERT_FALSE(tmp.IsSingleResourceModification());
+    ASSERT_THROW(tmp.GetOutputLevel(), OrthancException);
+    ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel());
+  }
+
+  {
+    // Test behavior on broken serialization
+    ASSERT_FALSE(s.isMember("OutputLevel"));
+    ASSERT_TRUE(s.isMember("IsSingleResource"));
+    s.removeMember("IsSingleResource");
+
+    {
+      std::unique_ptr<IJob> job;
+      job.reset(unserializer.UnserializeJob(s));
+
+      ResourceModificationJob& tmp = dynamic_cast<ResourceModificationJob&>(*job);
+      ASSERT_TRUE(tmp.IsAnonymization());
+      ASSERT_TRUE(tmp.IsSingleResourceModification());
+      ASSERT_EQ(ResourceType_Patient, tmp.GetOutputLevel());
+      ASSERT_EQ(ResourceType_Instance, tmp.GetModification().GetLevel());
+    }
+
+    s["Modification"]["Level"] = "Series";
+
+    {
+      std::unique_ptr<IJob> job;
+      job.reset(unserializer.UnserializeJob(s));
+
+      ResourceModificationJob& tmp = dynamic_cast<ResourceModificationJob&>(*job);
+      ASSERT_TRUE(tmp.IsAnonymization());
+      ASSERT_TRUE(tmp.IsSingleResourceModification());
+      ASSERT_EQ(ResourceType_Series, tmp.GetOutputLevel());
+      ASSERT_EQ(ResourceType_Series, tmp.GetModification().GetLevel());
+    }
   }
 
   // SplitStudyJob