changeset 6033:6ad530603d23 pixel-anon

wip: pixel-anon: now modifying pixels into specific instances only
author Alain Mazy <am@orthanc.team>
date Fri, 07 Mar 2025 17:25:34 +0100
parents c76b1b2ee57e
children ee903fefedbf
files OrthancFramework/Sources/DicomParsing/DicomModification.cpp OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h OrthancServer/Sources/ServerContext.cpp TODO
diffstat 5 files changed, 152 insertions(+), 13 deletions(-) [+]
line wrap: on
line diff
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Thu Mar 06 19:10:32 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Fri Mar 07 17:25:34 2025 +0100
@@ -1007,8 +1007,16 @@
       }
     }
 
+    // (0.1) New in Orthanc 1.X.X: Apply pixel modifications
+    // This is done before modifying any tags because the pixelMasker has filters on the Orthanc ids ->
+    // the DICOM UID tags must not be modified before.
+    if (pixelMasker_ != NULL)
+    {
+      pixelMasker_->Apply(toModify);
+    }
 
-    // (0) Create a summary of the source file, if a custom generator
+
+    // (0.2) Create a summary of the source file, if a custom generator
     // is provided
     if (identifierGenerator_ != NULL)
     {
@@ -1146,11 +1154,6 @@
                            DicomReplaceMode_InsertIfAbsent, privateCreator_);
     }
 
-    // (9) New in Orthanc 1.X.X: Apply pixel modifications
-    if (pixelMasker_ != NULL)
-    {
-      pixelMasker_->Apply(toModify);
-    }
   }
 
   void DicomModification::SetAllowManualIdentifiers(bool check)
@@ -1322,6 +1325,13 @@
       privateCreator_ = SerializationToolbox::ReadString(request, "PrivateCreator");
     }
 
+    // New in Orthanc 1.X.X
+    if (request.isMember("MaskPixelData") && request["MaskPixelData"].isObject())
+    {
+      pixelMasker_.reset(new DicomPixelMasker());
+      pixelMasker_->ParseRequest(request);
+    }
+
     if (!force)
     {
       /**
--- a/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp	Thu Mar 06 19:10:32 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp	Fri Mar 07 17:25:34 2025 +0100
@@ -27,7 +27,7 @@
 #include "DicomPixelMasker.h"
 #include "../OrthancException.h"
 #include "../SerializationToolbox.h"
-
+#include "../Logging.h"
 
 namespace Orthanc
 {
@@ -44,6 +44,7 @@
   static const char* KEY_ORIGIN = "Origin";
   static const char* KEY_END = "End";
   static const char* KEY_TARGET_SERIES = "TargetSeries";
+  static const char* KEY_TARGET_INSTANCES = "TargetInstances";
 
   DicomPixelMasker::DicomPixelMasker()
   {
@@ -51,10 +52,25 @@
 
   void DicomPixelMasker::Apply(ParsedDicomFile& toModify)
   {
+    DicomInstanceHasher hasher = toModify.GetHasher();
+    const std::string& seriesId = hasher.HashSeries();
+    const std::string& instanceId = hasher.HashInstance();
+
     for (std::list<Region>::const_iterator r = regions_.begin(); r != regions_.end(); ++r)
     {
+      // LOG(INFO) << " +++ " << seriesId << "   " << instanceId;
+      if (r->targetSeries_.size() > 0 && r->targetSeries_.find(seriesId) == r->targetSeries_.end())
+      {
+        continue;
+      }
+
+      if (r->targetInstances_.size() > 0 && r->targetInstances_.find(instanceId) == r->targetInstances_.end())
+      {
+        continue;
+      }
+
       ImageAccessor imageRegion;
-      toModify.GetRawFrame(0)->GetRegion(imageRegion, r->x_, r->y_, r->width_, r->height_);
+      toModify.GetRawFrame(0)->GetRegion(imageRegion, r->x_, r->y_, r->width_, r->height_);  // TODO-PIXEL-ANON: handle frames
 
       if (r->mode_ == DicomPixelMaskerMode_MeanFilter)
       {
@@ -110,7 +126,12 @@
 
           if (regionJson.isMember(KEY_TARGET_SERIES) && regionJson[KEY_TARGET_SERIES].isArray())
           {
-            SerializationToolbox::ReadListOfStrings(region.targetSeries_, regionJson, KEY_TARGET_SERIES);
+            SerializationToolbox::ReadSetOfStrings(region.targetSeries_, regionJson, KEY_TARGET_SERIES);
+          }
+
+          if (regionJson.isMember(KEY_TARGET_INSTANCES) && regionJson[KEY_TARGET_INSTANCES].isArray())
+          {
+            SerializationToolbox::ReadSetOfStrings(region.targetInstances_, regionJson, KEY_TARGET_INSTANCES);
           }
 
           if (regionJson.isMember(KEY_REGION_TYPE) && regionJson[KEY_REGION_TYPE].asString() == KEY_REGION_PIXELS)
--- a/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h	Thu Mar 06 19:10:32 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h	Fri Mar 07 17:25:34 2025 +0100
@@ -27,7 +27,7 @@
 #include "ParsedDicomFile.h"
 #include "../Images/ImageProcessing.h"
 
-#include <list>
+#include <set>
 
 
 namespace Orthanc
@@ -49,7 +49,8 @@
       DicomPixelMaskerMode    mode_;
       int32_t                 fillValue_;  // pixel value
       uint32_t                filterWidth_;  // filter width
-      std::list<std::string>  targetSeries_;
+      std::set<std::string>  targetSeries_;
+      std::set<std::string>  targetInstances_;
 
       Region() :
         x_(0),
--- a/OrthancServer/Sources/ServerContext.cpp	Thu Mar 06 19:10:32 2025 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Fri Mar 07 17:25:34 2025 +0100
@@ -2020,11 +2020,14 @@
                              unsigned int lossyQuality,
                              bool keepSOPInstanceUidDuringLossyTranscoding)
   {
+    const std::string originalSopInstanceUid = IDicomTranscoder::GetSopInstanceUid(dicomFile->GetDcmtkObject());  
+    std::string forceModifiedSopInstanceUid;
+
     // do we need to transcode before ?
     DicomTransferSyntax currentTransferSyntax;
     if (modification.RequiresUncompressedTransferSyntax() && 
         dicomFile->LookupTransferSyntax(currentTransferSyntax) &&
-        currentTransferSyntax > DicomTransferSyntax_BigEndianExplicit)
+        currentTransferSyntax > DicomTransferSyntax_BigEndianExplicit)  // TODO-PIXEL-ANON: write a function IsRawTransferSyntax()
     {
       IDicomTranscoder::DicomImage source;
       source.AcquireParsed(*dicomFile);  // "dicomFile" is invalid below this point
@@ -2034,7 +2037,27 @@
       std::set<DicomTransferSyntax> uncompressedTransferSyntax;
       uncompressedTransferSyntax.insert(DicomTransferSyntax_LittleEndianExplicit);
       Transcode(transcoded, source, uncompressedTransferSyntax, true);
-      
+
+      if (currentTransferSyntax == DicomTransferSyntax_JPEGProcess1)  // TODO-PIXEL-ANON: write a function IsLossyTransferSyntax()
+      {
+        // TODO-PIXEL-ANON:  Test this path with IngestTranscoding = lossy syntax
+        // TODO-PIXEL-ANON:  + Test with source = lossy + modification requires a transcoding to a raw TS -> the transcoding shall not be performed after pixel modification since it is already being done now but the SOPInstance UID must be changed afterwards
+        
+        // this means we have moved from lossy to raw -> the SOPInstanceUID should have changed here but, 
+        // keep the SOPInstanceUID unchanged during this pre-transcoding to make sure the orthanc ids are 
+        // still the original ones when the pixelMasker is applied (since the pixelMasker has a filter on Orthanc ids).
+        // however, after the modification, we must make sure that we change the SOPInstanceUID.
+        if (dicomFile.get() && dicomFile->GetDcmtkObject().getDataset())
+        {
+          const char* sopInstanceUid;
+          if (dicomFile->GetDcmtkObject().getDataset()->findAndGetString(DCM_SOPInstanceUID, sopInstanceUid, OFFalse).good())
+          {
+            forceModifiedSopInstanceUid = sopInstanceUid;
+            dicomFile->GetDcmtkObject().getDataset()->putAndInsertString(DCM_SOPInstanceUID, originalSopInstanceUid.c_str(), OFTrue /* replace */);
+          }
+        }
+      }
+
       if (!transcode) // if we had to change the TS for the modification, we need to restore the original TS afterwards
       {
         transcode = true;
@@ -2083,6 +2106,7 @@
                                std::string(GetTransferSyntaxUid(targetSyntax)));
       }
     }
+    // TODO-PIXEL-ANON: set forceModifiedSopInstanceUid if required
   }
 
 
--- a/TODO	Thu Mar 06 19:10:32 2025 +0100
+++ b/TODO	Fri Mar 07 17:25:34 2025 +0100
@@ -413,3 +413,86 @@
 
 * Create REST bindings with Slicer
 * Create REST bindings with Horos/OsiriX
+
+
+
+
+
+
+
+WORK IN PROGRESS: pixel-anon API
+================================
+
+Rest API proposal:
+
+{
+	"MaskPixelData" : {
+		"Regions": [
+			{
+				"MaskType": "MeanFilter",
+				"FilterWidth": 20,
+				"RegionType" : "Pixels",
+				"Origin": [150, 100],            // X, Y in pixel coordinates
+				"End": [400, 200],               // X, Y in pixel coordinates
+				"TargetSeries" : [               // the series the pixel mask applies to.  If empty -> applies to all series
+					"cd589a09-6e705e06-57997219-7812eb49-709873a9"
+				],
+        "TargetInstances" : [            // the instances the pixel mask applies to.  If empty -> applies to all instances
+        ]
+			},
+			{                                    
+				"MaskType": "Fill",
+				"FillValue": 0,
+				"RegionType" : "BoundingBox",       // area is defined by a volume in world coordinates  (TODO-PIXEL-ANON)
+				"Origin": [-150.5, -250.4, -811],   // X, Y, Z in World coordinates
+				"End": [148.4, 220.7, -955],        // X, Y, Z in World coordinates
+				"TargetSeries" : [                  // in this mode, no need to list the instances since the Z coordinate shall handle that !
+					"94df9100-3b476f5b-f4e8c381-d78c327f-a387bc7e"
+				]
+			}
+		]
+	}
+}
+
+# anonymize a single instance
+curl http://localhost:8043/instances/19565ed8-6bd8f20e-efbd6c34-36688133-bc91329e/anonymize --data-binary '{"MaskPixelData" : {"Regions": [{"MaskType": "MeanFilter", "FilterWidth": 20, "RegionType" : "Pixels", "Origin": [150, 100], "End": [400, 200]} ]} }' --output out.dcm
+curl http://localhost:8043/instances/19565ed8-6bd8f20e-efbd6c34-36688133-bc91329e/anonymize --data-binary '{"MaskPixelData" : {"Regions": [{"MaskType": "Fill", "FillValue": 0, "RegionType" : "Pixels", "Origin": [150, 100], "End": [400, 200]} ]} }' --output out.dcm
+
+# modify all slices of one series of a study
+curl -X POST http://localhost:8043/studies/321d3848-40c81c82-49f6f235-df6b1ec7-ed52f2fa/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.3", "StudyDescription": "modified-all-slices"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 4000,
+            "RegionType" : "Pixels",
+            "Origin": [150, 100],
+            "End": [400, 200],
+            "TargetSeries" : ["d5f6c1a2-d6f2f01a-a3cc4e8b-424476dc-f34b0cd1"]
+      }]
+    }
+}
+EOF
+
+
+# modify a few slices of one series of a study
+curl -X POST http://localhost:8043/studies/321d3848-40c81c82-49f6f235-df6b1ec7-ed52f2fa/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.3", "StudyDescription": "modified-few-slices"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 4000,
+            "RegionType" : "Pixels",
+            "Origin": [150, 100],
+            "End": [400, 200],
+            "TargetInstances" : ["11e0b13a-fb44c3d5-819193d8-314b3bb5-4da13bc4", "372d70bb-886e6cc5-a89eefcc-405b2346-f4676bb2"]
+      }]
+    }
+}
+EOF