changeset 6421:d0920767534e pixel-anon

integration mainline->pixel-anon
author Sebastien Jodogne <s.jodogne@gmail.com>
date Sat, 15 Nov 2025 12:27:24 +0100
parents ded8a2be0d46 (diff) c557f6bdcbfd (current diff)
children
files OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake OrthancFramework/Sources/Enumerations.h OrthancFramework/Sources/Images/ImageProcessing.cpp OrthancServer/Sources/ServerContext.cpp
diffstat 23 files changed, 1420 insertions(+), 104 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Sat Nov 15 11:49:39 2025 +0100
+++ b/.hgignore	Sat Nov 15 12:27:24 2025 +0100
@@ -9,6 +9,7 @@
 *~
 *.cmake.orig
 .idea/
+OrthancFramework/Resources/CodeGeneration/.venv/
 
 # when opening Orthanc in VSCode, it might find a java project and create files we wan't to ignore:
 .settings/
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Sat Nov 15 12:27:24 2025 +0100
@@ -549,6 +549,7 @@
   set(ORTHANC_DICOM_SOURCES_INTERNAL
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomNetworking/DicomFindAnswers.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomParsing/DicomModification.cpp
+    ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomParsing/DicomPixelMasker.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomParsing/DicomWebJsonVisitor.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomParsing/FromDcmtkBridge.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/DicomParsing/ParsedDicomCache.cpp
--- a/OrthancFramework/Resources/CodeGeneration/DicomTransferSyntaxes.json	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Resources/CodeGeneration/DicomTransferSyntaxes.json	Sat Nov 15 12:27:24 2025 +0100
@@ -5,7 +5,8 @@
     "Value" : "LittleEndianImplicit",
     "Retired" : false,
     "DCMTK" : "EXS_LittleEndianImplicit",
-    "GDCM" : "gdcm::TransferSyntax::ImplicitVRLittleEndian"
+    "GDCM" : "gdcm::TransferSyntax::ImplicitVRLittleEndian",
+    "Raw": true, "Lossless": true
   },
   
   {
@@ -14,7 +15,8 @@
     "Value" : "LittleEndianExplicit",
     "Retired" : false,
     "DCMTK" : "EXS_LittleEndianExplicit",
-    "GDCM" : "gdcm::TransferSyntax::ExplicitVRLittleEndian"
+    "GDCM" : "gdcm::TransferSyntax::ExplicitVRLittleEndian",
+    "Raw": true, "Lossless": true
   },
   
   {
@@ -22,7 +24,8 @@
     "Name" : "Deflated Explicit VR Little Endian",
     "Value" : "DeflatedLittleEndianExplicit",
     "Retired" : false,
-    "DCMTK" : "EXS_DeflatedLittleEndianExplicit"
+    "DCMTK" : "EXS_DeflatedLittleEndianExplicit",
+    "Raw": false, "Lossless": true
   },
   
   {
@@ -30,7 +33,8 @@
     "Name" : "Explicit VR Big Endian",
     "Value" : "BigEndianExplicit",
     "Retired" : false,
-    "DCMTK" : "EXS_BigEndianExplicit"
+    "DCMTK" : "EXS_BigEndianExplicit",
+    "Raw": true, "Lossless": true
   },
   
   {
@@ -41,7 +45,8 @@
     "Note" : "Default Transfer Syntax for Lossy JPEG 8-bit Image Compression",
     "DCMTK" : "EXS_JPEGProcess1",
     "DCMTK360" : "EXS_JPEGProcess1TransferSyntax",
-    "GDCM" : "gdcm::TransferSyntax::JPEGBaselineProcess1"
+    "GDCM" : "gdcm::TransferSyntax::JPEGBaselineProcess1",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -52,7 +57,8 @@
     "Note" : "Default Transfer Syntax for Lossy JPEG (lossy, 8/12 bit), 12-bit Image Compression (Process 4 only)",
     "DCMTK" : "EXS_JPEGProcess2_4",
     "DCMTK360" : "EXS_JPEGProcess2_4TransferSyntax",
-    "GDCM" : "gdcm::TransferSyntax::JPEGExtendedProcess2_4"
+    "GDCM" : "gdcm::TransferSyntax::JPEGExtendedProcess2_4",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -61,7 +67,8 @@
     "Value" : "JPEGProcess3_5",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess3_5",
-    "DCMTK360" : "EXS_JPEGProcess3_5TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess3_5TransferSyntax",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -70,7 +77,8 @@
     "Value" : "JPEGProcess6_8",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess6_8",
-    "DCMTK360" : "EXS_JPEGProcess6_8TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess6_8TransferSyntax",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -79,7 +87,8 @@
     "Value" : "JPEGProcess7_9",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess7_9",
-    "DCMTK360" : "EXS_JPEGProcess7_9TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess7_9TransferSyntax",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -88,7 +97,8 @@
     "Value" : "JPEGProcess10_12",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess10_12",
-    "DCMTK360" : "EXS_JPEGProcess10_12TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess10_12TransferSyntax",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -97,7 +107,8 @@
     "Value" : "JPEGProcess11_13",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess11_13",
-    "DCMTK360" : "EXS_JPEGProcess11_13TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess11_13TransferSyntax",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -107,7 +118,8 @@
     "Retired" : false,
     "DCMTK" : "EXS_JPEGProcess14",
     "DCMTK360" : "EXS_JPEGProcess14TransferSyntax",
-    "GDCM" : "gdcm::TransferSyntax::JPEGLosslessProcess14"
+    "GDCM" : "gdcm::TransferSyntax::JPEGLosslessProcess14",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -116,7 +128,8 @@
     "Value" : "JPEGProcess15",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess15",
-    "DCMTK360" : "EXS_JPEGProcess15TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess15TransferSyntax",
+    "Raw": false, "Lossless": true
   },
   
   {
@@ -125,7 +138,8 @@
     "Value" : "JPEGProcess16_18",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess16_18",
-    "DCMTK360" : "EXS_JPEGProcess16_18TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess16_18TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -134,7 +148,8 @@
     "Value" : "JPEGProcess17_19",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess17_19",
-    "DCMTK360" : "EXS_JPEGProcess17_19TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess17_19TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -143,7 +158,8 @@
     "Value" : "JPEGProcess20_22",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess20_22",
-    "DCMTK360" : "EXS_JPEGProcess20_22TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess20_22TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -152,7 +168,8 @@
     "Value" : "JPEGProcess21_23",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess21_23",
-    "DCMTK360" : "EXS_JPEGProcess21_23TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess21_23TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -161,7 +178,8 @@
     "Value" : "JPEGProcess24_26",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess24_26",
-    "DCMTK360" : "EXS_JPEGProcess24_26TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess24_26TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -170,7 +188,8 @@
     "Value" : "JPEGProcess25_27",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess25_27",
-    "DCMTK360" : "EXS_JPEGProcess25_27TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess25_27TransferSyntax",
+    "Raw": false, "Lossless": false
   },
   
   {
@@ -179,7 +198,8 @@
     "Value" : "JPEGProcess28",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess28",
-    "DCMTK360" : "EXS_JPEGProcess28TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess28TransferSyntax",
+    "Raw": false, "Lossless": true
   },
   
   {
@@ -188,7 +208,8 @@
     "Value" : "JPEGProcess29",
     "Retired" : true,
     "DCMTK" : "EXS_JPEGProcess29",
-    "DCMTK360" : "EXS_JPEGProcess29TransferSyntax"
+    "DCMTK360" : "EXS_JPEGProcess29TransferSyntax",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -199,7 +220,8 @@
     "Note" : "Default Transfer Syntax for Lossless JPEG Image Compression",
     "DCMTK" : "EXS_JPEGProcess14SV1",
     "DCMTK360" : "EXS_JPEGProcess14SV1TransferSyntax",
-    "GDCM" : "gdcm::TransferSyntax::JPEGLosslessProcess14_1"
+    "GDCM" : "gdcm::TransferSyntax::JPEGLosslessProcess14_1",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -208,7 +230,8 @@
     "Value" : "JPEGLSLossless",
     "Retired" : false,
     "DCMTK" : "EXS_JPEGLSLossless",
-    "GDCM" : "gdcm::TransferSyntax::JPEGLSLossless"
+    "GDCM" : "gdcm::TransferSyntax::JPEGLSLossless",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -217,7 +240,8 @@
     "Value" : "JPEGLSLossy",
     "Retired" : false,
     "DCMTK" : "EXS_JPEGLSLossy",
-    "GDCM" : "gdcm::TransferSyntax::JPEGLSNearLossless"
+    "GDCM" : "gdcm::TransferSyntax::JPEGLSNearLossless",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -226,7 +250,8 @@
     "Value" : "JPEG2000LosslessOnly",
     "Retired" : false,
     "DCMTK" : "EXS_JPEG2000LosslessOnly",
-    "GDCM" : "gdcm::TransferSyntax::JPEG2000Lossless"
+    "GDCM" : "gdcm::TransferSyntax::JPEG2000Lossless",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -235,7 +260,8 @@
     "Value" : "JPEG2000",
     "Retired" : false,
     "DCMTK" : "EXS_JPEG2000",
-    "GDCM" : "gdcm::TransferSyntax::JPEG2000"
+    "GDCM" : "gdcm::TransferSyntax::JPEG2000",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -244,7 +270,8 @@
     "Value" : "JPEG2000MulticomponentLosslessOnly",
     "Retired" : false,
     "DCMTK" : "EXS_JPEG2000MulticomponentLosslessOnly",
-    "GDCM" : "gdcm::TransferSyntax::JPEG2000Part2Lossless"
+    "GDCM" : "gdcm::TransferSyntax::JPEG2000Part2Lossless",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -253,7 +280,8 @@
     "Value" : "JPEG2000Multicomponent",
     "Retired" : false,
     "DCMTK" : "EXS_JPEG2000Multicomponent",
-    "GDCM" : "gdcm::TransferSyntax::JPEG2000Part2"
+    "GDCM" : "gdcm::TransferSyntax::JPEG2000Part2",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -261,7 +289,8 @@
     "Name" : "JPIP Referenced",
     "Value" : "JPIPReferenced",
     "Retired" : false,
-    "DCMTK" : "EXS_JPIPReferenced"
+    "DCMTK" : "EXS_JPIPReferenced",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -269,7 +298,8 @@
     "Name" : "JPIP Referenced Deflate",
     "Value" : "JPIPReferencedDeflate",
     "Retired" : false,
-    "DCMTK" : "EXS_JPIPReferencedDeflate"
+    "DCMTK" : "EXS_JPIPReferencedDeflate",
+    "Raw": false, "Lossless": true
   },
 
   {
@@ -277,7 +307,8 @@
     "Name" : "MPEG2 Main Profile / Main Level",
     "Value" : "MPEG2MainProfileAtMainLevel",
     "Retired" : false,
-    "DCMTK" : "EXS_MPEG2MainProfileAtMainLevel"
+    "DCMTK" : "EXS_MPEG2MainProfileAtMainLevel",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -285,7 +316,8 @@
     "Name" : "MPEG2 Main Profile / High Level",
     "Value" : "MPEG2MainProfileAtHighLevel",
     "Retired" : false,
-    "DCMTK" : "EXS_MPEG2MainProfileAtHighLevel"
+    "DCMTK" : "EXS_MPEG2MainProfileAtHighLevel",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -294,7 +326,8 @@
     "Value" : "MPEG4HighProfileLevel4_1",
     "Retired" : false,
     "DCMTK" : "EXS_MPEG4HighProfileLevel4_1",
-    "SinceDCMTK" : "361"
+    "SinceDCMTK" : "361",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -303,7 +336,8 @@
     "Value" : "MPEG4BDcompatibleHighProfileLevel4_1",
     "Retired" : false,
     "DCMTK" : "EXS_MPEG4BDcompatibleHighProfileLevel4_1",
-    "SinceDCMTK" : "361"
+    "SinceDCMTK" : "361",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -312,7 +346,8 @@
     "Value" : "MPEG4HighProfileLevel4_2_For2DVideo",
     "Retired" : false,
     "DCMTK" : "EXS_MPEG4HighProfileLevel4_2_For2DVideo",
-    "SinceDCMTK" : "361"
+    "SinceDCMTK" : "361",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -321,7 +356,8 @@
     "Value" : "MPEG4HighProfileLevel4_2_For3DVideo",
     "Retired" : false,
     "DCMTK" : "EXS_MPEG4HighProfileLevel4_2_For3DVideo",
-    "SinceDCMTK" : "361"
+    "SinceDCMTK" : "361",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -330,7 +366,8 @@
     "Value" : "MPEG4StereoHighProfileLevel4_2",
     "Retired" : false,
     "DCMTK" : "EXS_MPEG4StereoHighProfileLevel4_2",
-    "SinceDCMTK" : "361"
+    "SinceDCMTK" : "361",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -339,7 +376,8 @@
     "Value" : "HEVCMainProfileLevel5_1",
     "Retired" : false,
     "DCMTK" : "EXS_HEVCMainProfileLevel5_1",
-    "SinceDCMTK" : "362"
+    "SinceDCMTK" : "362",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -348,7 +386,8 @@
     "Value" : "HEVCMain10ProfileLevel5_1",
     "Retired" : false,
     "DCMTK" : "EXS_HEVCMain10ProfileLevel5_1",
-    "SinceDCMTK" : "362"
+    "SinceDCMTK" : "362",
+    "Raw": false, "Lossless": false
   },
 
   {
@@ -357,7 +396,8 @@
     "Value" : "RLELossless",
     "Retired" : false,
     "DCMTK" : "EXS_RLELossless",
-    "GDCM" : "gdcm::TransferSyntax::RLELossless"
+    "GDCM" : "gdcm::TransferSyntax::RLELossless",
+    "Raw": false, "Lossless": true
   },
 
   {
--- a/OrthancFramework/Resources/CodeGeneration/GenerateTransferSyntaxesEnumerations.mustache	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Resources/CodeGeneration/GenerateTransferSyntaxesEnumerations.mustache	Sat Nov 15 12:27:24 2025 +0100
@@ -82,4 +82,44 @@
     target.insert(DicomTransferSyntax_{{Value}});
     {{/Syntaxes}}
   }
+
+
+  bool IsLossyTransferSyntax(DicomTransferSyntax syntax)
+  {
+    switch (syntax)
+    {
+      {{#Syntaxes}}
+      case DicomTransferSyntax_{{Value}}:
+        {{#Lossless}}
+        return false;
+        {{/Lossless}}
+        {{^Lossless}}
+        return true;
+        {{/Lossless}}
+
+      {{/Syntaxes}}
+      default:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+  }
+
+
+  bool IsRawTransferSyntax(DicomTransferSyntax syntax)
+  {
+    switch (syntax)
+    {
+      {{#Syntaxes}}
+      case DicomTransferSyntax_{{Value}}:
+        {{#Raw}}
+        return true;
+        {{/Raw}}
+        {{^Raw}}
+        return false;
+        {{/Raw}}
+
+      {{/Syntaxes}}
+      default:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+  }
 }
--- a/OrthancFramework/Sources/DicomFormat/DicomTag.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomFormat/DicomTag.h	Sat Nov 15 12:27:24 2025 +0100
@@ -102,6 +102,8 @@
   static const DicomTag DICOM_TAG_SERIES_DESCRIPTION(0x0008, 0x103e);
   static const DicomTag DICOM_TAG_MODALITY(0x0008, 0x0060);
 
+  static const DicomTag DICOM_TAG_DETECTOR_INFORMATION_SEQUENCE(0x0054, 0x0022);
+
   // The following is used for "modify/anonymize" operations
   static const DicomTag DICOM_TAG_SOP_CLASS_UID(0x0008, 0x0016);
   static const DicomTag DICOM_TAG_MEDIA_STORAGE_SOP_CLASS_UID(0x0002, 0x0002);
@@ -196,6 +198,7 @@
   static const DicomTag DICOM_TAG_RESCALE_INTERCEPT(0x0028, 0x1052);
   static const DicomTag DICOM_TAG_RESCALE_SLOPE(0x0028, 0x1053);
   static const DicomTag DICOM_TAG_SLICE_THICKNESS(0x0018, 0x0050);
+  static const DicomTag DICOM_TAG_SPACING_BETWEEN_SLICES(0x0018, 0x0088);
   static const DicomTag DICOM_TAG_WINDOW_CENTER(0x0028, 0x1050);
   static const DicomTag DICOM_TAG_WINDOW_WIDTH(0x0028, 0x1051);
   static const DicomTag DICOM_TAG_DOSE_GRID_SCALING(0x3004, 0x000e);
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -31,6 +31,7 @@
 #include "../SerializationToolbox.h"
 #include "FromDcmtkBridge.h"
 #include "ITagVisitor.h"
+#include "DicomPixelMasker.h"
 
 #include <memory>   // For std::unique_ptr
 
@@ -1015,8 +1016,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)
     {
@@ -1153,6 +1162,7 @@
       toModify.ReplacePath((*it)->GetPath(), (*it)->GetValue(), true /* decode data URI scheme */,
                            DicomReplaceMode_InsertIfAbsent, privateCreator_);
     }
+
   }
 
   void DicomModification::SetAllowManualIdentifiers(bool check)
@@ -1324,6 +1334,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)
     {
       /**
@@ -1387,7 +1404,7 @@
   {
     if (!request.isObject())
     {
-      throw OrthancException(ErrorCode_BadFileFormat);
+      throw OrthancException(ErrorCode_BadFileFormat, "The payload should be a JSON object.");
     }
 
     bool force = GetBooleanValue("Force", request, false);
@@ -1401,7 +1418,7 @@
     {
       if (request["DicomVersion"].type() != Json::stringValue)
       {
-        throw OrthancException(ErrorCode_BadFileFormat);
+        throw OrthancException(ErrorCode_BadFileFormat, "DicomVersion should be a string");
       }
       else
       {
@@ -1443,6 +1460,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);
+    }
   }
 
   void DicomModification::SetDicomIdentifierGenerator(DicomModification::IDicomIdentifierGenerator &generator)
@@ -1904,4 +1928,9 @@
       target.insert(it->first);
     }
   }
+
+  bool DicomModification::RequiresUncompressedTransferSyntax() const
+  {
+    return pixelMasker_ != NULL;
+  }
 }
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.h	Sat Nov 15 12:27:24 2025 +0100
@@ -31,6 +31,8 @@
 
 namespace Orthanc
 {
+  class DicomPixelMasker;
+
   class ORTHANC_PUBLIC DicomModification : public boost::noncopyable
   {
     /**
@@ -154,6 +156,9 @@
     ListOfPaths          removeSequences_;       // Must *never* be a path whose prefix is empty
     SequenceReplacements sequenceReplacements_;  // Must *never* be a path whose prefix is empty
 
+    // New in Orthanc 1.X.X
+    std::unique_ptr<DicomPixelMasker>     pixelMasker_;    // TODO-PIXEL-ANON: check ownership & serialization
+
     std::string MapDicomIdentifier(const std::string& original,
                                    ResourceType level);
 
@@ -268,5 +273,7 @@
                  bool safeForAnonymization);
 
     bool IsAlteredTag(const DicomTag& tag) const;
+
+    bool RequiresUncompressedTransferSyntax() const;
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -0,0 +1,386 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2025 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "../PrecompiledHeaders.h"
+
+#include "DicomPixelMasker.h"
+#include "../OrthancException.h"
+#include "../SerializationToolbox.h"
+#include "../Logging.h"
+
+namespace Orthanc
+{
+  static const char* KEY_MASK_PIXELS = "MaskPixelData";
+  static const char* KEY_MASK_TYPE = "MaskType";
+  static const char* KEY_MASK_TYPE_FILL = "Fill";
+  static const char* KEY_MASK_TYPE_MEAN_FILTER = "MeanFilter";
+  static const char* KEY_FILTER_WIDTH = "FilterWidth";
+  static const char* KEY_FILL_VALUE = "FillValue";
+  static const char* KEY_REGIONS = "Regions";
+  static const char* KEY_REGION_TYPE = "RegionType";
+  static const char* KEY_REGION_2D = "2D";
+  static const char* KEY_REGION_3D = "3D";
+  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()
+  {
+  }
+
+  DicomPixelMasker::~DicomPixelMasker()
+  {
+    for (std::list<BaseRegion*>::iterator it = regions_.begin(); it != regions_.end(); ++it)
+    {
+      delete *it;
+    }
+  }
+
+  DicomPixelMasker::BaseRegion::BaseRegion() :
+    mode_(DicomPixelMaskerMode_Undefined),
+    fillValue_(0),
+    filterWidth_(0)
+  {
+  }
+
+  DicomPixelMasker::Region2D::Region2D(unsigned int x1, unsigned int y1, unsigned int x2, unsigned int y2) :
+    x1_(x1),
+    y1_(y1),
+    x2_(x2),
+    y2_(y2)
+  {
+  }
+
+  DicomPixelMasker::Region3D::Region3D(double x1, double y1, double z1, double x2, double y2, double z2) :
+    x1_(x1),
+    y1_(y1),
+    z1_(z1),
+    x2_(x2),
+    y2_(y2),
+    z2_(z2)
+  {
+  }
+
+  bool DicomPixelMasker::BaseRegion::IsTargeted(const ParsedDicomFile& file) const
+  {
+    DicomInstanceHasher hasher = file.GetHasher();
+    const std::string& seriesId = hasher.HashSeries();
+    const std::string& instanceId = hasher.HashInstance();
+
+    if (targetSeries_.size() > 0 && targetSeries_.find(seriesId) == targetSeries_.end())
+    {
+      return false;
+    }
+
+    if (targetInstances_.size() > 0 && targetInstances_.find(instanceId) == targetInstances_.end())
+    {
+      return false;
+    }
+
+    return true;
+  }
+
+  bool DicomPixelMasker::Region2D::GetPixelMaskArea(unsigned int& x1, unsigned int& y1, unsigned int& x2, unsigned int& y2, const ParsedDicomFile& file, unsigned int frameIndex) const
+  {
+    if (IsTargeted(file))
+    {
+      x1 = x1_;
+      y1 = y1_;
+      x2 = x2_;
+      y2 = y2_;
+      return true;
+    }
+
+    return false;
+  }
+
+  static void GetDoubleVector(std::vector<double>& target, const std::string& strValue, const DicomTag& tag, size_t expectedSize)
+  {
+    target.clear();
+
+    std::vector<std::string> strVector;
+    Toolbox::SplitString(strVector, strValue, '\\');
+
+    if (strVector.size() != expectedSize)
+    {
+      throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, tag " + tag.Format() + " length is invalid");
+    }
+
+    for (size_t i = 0; i < strVector.size(); ++i)
+    {
+      try
+      {
+        target.push_back(boost::lexical_cast<double>(strVector[i]));
+      }
+      catch (boost::bad_lexical_cast&)
+      {
+        throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, tag " + tag.Format() + " contains invalid value " + strVector[i]);
+      }
+    }
+  }
+
+  static void GetDoubleVector(std::vector<double>& target, const ParsedDicomFile& file, const DicomTag& tag, size_t expectedSize)
+  {
+    std::string str;
+    if (!file.GetTagValue(str, tag))
+    {
+      throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, missing tag" + tag.Format());
+    }
+
+    GetDoubleVector(target, str, tag, expectedSize);
+  }
+
+  bool DicomPixelMasker::Region3D::GetPixelMaskArea(unsigned int& x1, unsigned int& y1, unsigned int& x2, unsigned int& y2, const ParsedDicomFile& file, unsigned int frameIndex) const
+  {
+    if (IsTargeted(file))
+    {
+      DicomMap tags;
+      file.ExtractDicomSummary(tags, 256);
+
+      std::vector<double> imagePositionPatient;
+      std::vector<double> imageOrientationPatient;
+      std::vector<double> pixelSpacing;
+      double sliceSpacing = 0.0;
+
+      if (file.HasTag(DICOM_TAG_IMAGE_POSITION_PATIENT) && file.HasTag(DICOM_TAG_IMAGE_ORIENTATION_PATIENT))
+      {
+        GetDoubleVector(imagePositionPatient, file, DICOM_TAG_IMAGE_POSITION_PATIENT, 3);
+        GetDoubleVector(imageOrientationPatient, file, DICOM_TAG_IMAGE_ORIENTATION_PATIENT, 6);
+      }
+      else if (file.HasTag(DICOM_TAG_DETECTOR_INFORMATION_SEQUENCE)) // find it in the detector info sequence (for some multi frame instances like NM or scintigraphy) TODO-PIXEL-ANON: to validate
+      {
+        const Json::Value& jsonSequence = tags.GetValue(DICOM_TAG_DETECTOR_INFORMATION_SEQUENCE).GetSequenceContent();
+        if (jsonSequence.size() == 1)
+        {
+          std::string strImagePositionPatient = jsonSequence[0]["0020,0032"]["Value"].asString();
+          std::string strImageOrientationPatient = jsonSequence[0]["0020,0037"]["Value"].asString();
+
+          GetDoubleVector(imagePositionPatient, strImagePositionPatient, DICOM_TAG_IMAGE_POSITION_PATIENT, 3);
+          GetDoubleVector(imageOrientationPatient, strImageOrientationPatient, DICOM_TAG_IMAGE_ORIENTATION_PATIENT, 6);
+        }
+        else
+        {
+          throw OrthancException(ErrorCode_InternalError, "Unable to find ImagePositionPatient in DetectorInformationSequence, invalid sequence size");
+        }
+      }
+      else
+      {
+        throw OrthancException(ErrorCode_InternalError, "Unable to find ImagePositionPatient or ImageOrientationPatient");
+      }
+
+      GetDoubleVector(pixelSpacing, file, DICOM_TAG_PIXEL_SPACING, 2);
+
+      if (file.HasTag(DICOM_TAG_SPACING_BETWEEN_SLICES))
+      {
+        std::string strSliceSpacing;
+        if (file.GetTagValue(strSliceSpacing, DICOM_TAG_SPACING_BETWEEN_SLICES))
+        {
+          sliceSpacing = boost::lexical_cast<double>(strSliceSpacing);
+        }
+      }
+
+      double z = imagePositionPatient[2];
+      
+      if (sliceSpacing != 0.0)
+      {
+        z = z - frameIndex * sliceSpacing;
+      }
+
+      // note: To simplify, for the z, we only check that imagePositionPatient is between the authorized z values.
+      //       This won't be perfectly true for weird images with slices that are not parallel but let's wait for someone to complain ...
+      if (z < std::min(z1_, z2_) || 
+          z > std::max(z1_, z2_))
+      {
+        return false;
+      }
+      
+
+      double deltaX1 = x1_ - imagePositionPatient[0];
+      double deltaY1 = y1_ - imagePositionPatient[1];
+      double deltaZ1 = z1_ - z;
+      double deltaX2 = x2_ - imagePositionPatient[0];
+      double deltaY2 = y2_ - imagePositionPatient[1];
+      double deltaZ2 = z2_ - z;
+
+      double ix1 = (deltaX1 * imageOrientationPatient[0] + deltaY1 * imageOrientationPatient[1] + deltaZ1 * imageOrientationPatient[2]) / pixelSpacing[0];
+      double iy1 = (deltaX1 * imageOrientationPatient[3] + deltaY1 * imageOrientationPatient[4] + deltaZ1 * imageOrientationPatient[5]) / pixelSpacing[1];
+      double ix2 = (deltaX2 * imageOrientationPatient[0] + deltaY2 * imageOrientationPatient[1] + deltaZ2 * imageOrientationPatient[2]) / pixelSpacing[0];
+      double iy2 = (deltaX2 * imageOrientationPatient[3] + deltaY2 * imageOrientationPatient[4] + deltaZ2 * imageOrientationPatient[5]) / pixelSpacing[1];
+
+      std::string strRows;
+      std::string strColumns;
+
+      if (!file.GetTagValue(strRows, DICOM_TAG_ROWS) || !file.GetTagValue(strColumns, DICOM_TAG_COLUMNS))
+      {
+        throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, missing ROWS or COLUMNS tag");
+      }
+
+      // clip on image size
+      double rows = boost::lexical_cast<double>(strRows);
+      double columns = boost::lexical_cast<double>(strColumns);
+
+      x1 = static_cast<unsigned int>(std::max(0.0, std::min(ix1, ix2)));
+      y1 = static_cast<unsigned int>(std::max(0.0, std::min(iy1, iy2)));
+      x2 = static_cast<unsigned int>(std::min(columns, std::max(ix1, ix2)));
+      y2 = static_cast<unsigned int>(std::min(rows, std::max(iy1, iy2)));
+
+      return true;
+    }
+    
+    return false;
+  }
+
+  void DicomPixelMasker::Apply(ParsedDicomFile& toModify)
+  {
+    for (std::list<BaseRegion*>::const_iterator itr = regions_.begin(); itr != regions_.end(); ++itr)
+    {
+      const BaseRegion* r = *itr;
+
+      for (unsigned int i = 0; i < toModify.GetFramesCount(); ++i)
+      {
+        unsigned int x1, y1, x2, y2;
+
+        if (r->GetPixelMaskArea(x1, y1, x2, y2, toModify, i))
+        {
+          ImageAccessor imageRegion;
+          toModify.GetRawFrame(i)->GetRegion(imageRegion, x1, y1, x2 - x1, y2 - y1);
+
+          if (r->GetMode() == DicomPixelMaskerMode_MeanFilter)
+          {
+            ImageProcessing::MeanFilter(imageRegion, r->GetFilterWidth(), r->GetFilterWidth());
+          }
+          else if (r->GetMode() == DicomPixelMaskerMode_Fill)
+          {
+            ImageProcessing::Set(imageRegion, r->GetFillValue());
+          }
+        }
+      }
+    }
+  }
+
+  void DicomPixelMasker::ParseRequest(const Json::Value& request)
+  {
+    if (request.isMember(KEY_MASK_PIXELS) && request[KEY_MASK_PIXELS].isObject())
+    {
+      const Json::Value& maskPixelsJson = request[KEY_MASK_PIXELS];
+
+      if (maskPixelsJson.isMember(KEY_REGIONS) && maskPixelsJson[KEY_REGIONS].isArray())
+      {
+        const Json::Value& regionsJson = maskPixelsJson[KEY_REGIONS];
+
+        for (Json::ArrayIndex i = 0; i < regionsJson.size(); ++i)
+        {
+          const Json::Value& regionJson = regionsJson[i];
+
+          std::unique_ptr<BaseRegion> region;
+          
+          if (regionJson.isMember(KEY_REGION_TYPE) && regionJson[KEY_REGION_TYPE].isString())
+          {
+            if (regionJson[KEY_REGION_TYPE].asString() == KEY_REGION_2D)
+            {
+              if (regionJson.isMember(KEY_ORIGIN) && regionJson[KEY_ORIGIN].isArray() && regionJson[KEY_ORIGIN].size() == 2 &&
+                  regionJson.isMember(KEY_END) && regionJson[KEY_END].isArray() && regionJson[KEY_END].size() == 2)
+              {
+                unsigned int x = regionJson[KEY_ORIGIN][0].asUInt();
+                unsigned int y = regionJson[KEY_ORIGIN][1].asUInt();
+                unsigned int width = regionJson[KEY_END][0].asUInt() - x;
+                unsigned int height = regionJson[KEY_END][1].asUInt() - y;
+                
+                region.reset(new Region2D(x, y, width, height));
+              }
+              else
+              {
+                throw OrthancException(ErrorCode_BadFileFormat, "2D Region: invalid coordinates");
+              }
+
+            }
+            else if (regionJson[KEY_REGION_TYPE].asString() == KEY_REGION_3D)
+            {
+              if (regionJson.isMember(KEY_ORIGIN) && regionJson[KEY_ORIGIN].isArray() && regionJson[KEY_ORIGIN].size() == 3 &&
+                  regionJson.isMember(KEY_END) && regionJson[KEY_END].isArray() && regionJson[KEY_END].size() == 3)
+              {
+                double x1 = regionJson[KEY_ORIGIN][0].asDouble();
+                double y1 = regionJson[KEY_ORIGIN][1].asDouble();
+                double z1 = regionJson[KEY_ORIGIN][2].asDouble();
+                double x2 = regionJson[KEY_END][0].asDouble();
+                double y2 = regionJson[KEY_END][1].asDouble();
+                double z2 = regionJson[KEY_END][2].asDouble();
+                
+                region.reset(new Region3D(x1, y1, z1, x2, y2, z2));
+              }
+              else
+              {
+                throw OrthancException(ErrorCode_BadFileFormat, "2D Region: invalid coordinates");
+              }
+            }
+            else
+            {
+              throw OrthancException(ErrorCode_BadFileFormat, std::string(KEY_REGION_TYPE) + " unrecognized value '" + regionJson[KEY_REGION_TYPE].asString() +"'");
+            }
+          }
+
+          if (regionJson.isMember(KEY_MASK_TYPE) && regionJson[KEY_MASK_TYPE].isString())
+          {
+            if (regionJson[KEY_MASK_TYPE].asString() == KEY_MASK_TYPE_FILL)
+            {
+              if (regionJson.isMember(KEY_FILL_VALUE) && regionJson[KEY_FILL_VALUE].isInt())
+              {
+                region->SetFillValue(regionJson[KEY_FILL_VALUE].asInt());
+              }
+            }
+            else if (regionJson[KEY_MASK_TYPE].asString() == KEY_MASK_TYPE_MEAN_FILTER)
+            {
+              if (regionJson.isMember(KEY_FILTER_WIDTH) && regionJson[KEY_FILTER_WIDTH].isUInt())
+              {
+                region->SetMeanFilter(regionJson[KEY_FILTER_WIDTH].asUInt());
+              }
+            }
+            else
+            {
+              throw OrthancException(ErrorCode_BadFileFormat, std::string(KEY_MASK_TYPE) + " should be '" + KEY_MASK_TYPE_FILL +"' or '" + KEY_MASK_TYPE_MEAN_FILTER + "'.");
+            }
+          }
+
+          if (regionJson.isMember(KEY_TARGET_SERIES) && regionJson[KEY_TARGET_SERIES].isArray())
+          {
+            std::set<std::string> targetSeries;
+            SerializationToolbox::ReadSetOfStrings(targetSeries, regionJson, KEY_TARGET_SERIES);
+            region->SetTargetSeries(targetSeries);
+          }
+
+          if (regionJson.isMember(KEY_TARGET_INSTANCES) && regionJson[KEY_TARGET_INSTANCES].isArray())
+          {
+            std::set<std::string> targetInstances;
+            SerializationToolbox::ReadSetOfStrings(targetInstances, regionJson, KEY_TARGET_INSTANCES);
+            region->SetTargetInstances(targetInstances);
+          }
+
+          regions_.push_back(region.release());
+        }
+      }
+    }
+
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h	Sat Nov 15 12:27:24 2025 +0100
@@ -0,0 +1,145 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2025 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2025 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "ParsedDicomFile.h"
+#include "../Images/ImageProcessing.h"
+
+#include <set>
+
+
+namespace Orthanc
+{
+  enum DicomPixelMaskerMode
+  {
+    DicomPixelMaskerMode_Fill,
+    DicomPixelMaskerMode_MeanFilter,
+
+    DicomPixelMaskerMode_Undefined
+  };
+
+  class ORTHANC_PUBLIC DicomPixelMasker : public boost::noncopyable
+  {
+    class BaseRegion
+    {
+      DicomPixelMaskerMode    mode_;
+      int32_t                 fillValue_;     // pixel value
+      uint32_t                filterWidth_;   // filter width
+      std::set<std::string>   targetSeries_;
+      std::set<std::string>   targetInstances_;
+
+    protected:
+      bool IsTargeted(const ParsedDicomFile& file) const;
+      BaseRegion();
+
+    public:
+      
+      virtual ~BaseRegion()
+      {
+      }
+
+      virtual bool GetPixelMaskArea(unsigned int& x1, unsigned int& y1, unsigned int& x2, unsigned int& y2, const ParsedDicomFile& file, unsigned int frameIndex) const = 0;
+
+      DicomPixelMaskerMode GetMode() const
+      {
+        return mode_;
+      }
+
+      int32_t GetFillValue() const
+      {
+        assert(mode_ == DicomPixelMaskerMode_Fill);
+        return fillValue_;
+      }
+
+      int32_t GetFilterWidth() const
+      {
+        assert(mode_ == DicomPixelMaskerMode_MeanFilter);
+        return filterWidth_;
+      }
+
+      void SetFillValue(int32_t value)
+      {
+        mode_ = DicomPixelMaskerMode_Fill;
+        fillValue_ = value;
+      }
+
+      void SetMeanFilter(uint32_t value)
+      {
+        mode_ = DicomPixelMaskerMode_MeanFilter;
+        filterWidth_ = value;
+      }
+
+      void SetTargetSeries(const std::set<std::string> targetSeries)
+      {
+        targetSeries_ = targetSeries;
+      }
+
+      void SetTargetInstances(const std::set<std::string> targetInstances)
+      {
+        targetInstances_ = targetInstances;
+      }
+    };
+
+    class Region2D : public BaseRegion
+    {
+      unsigned int            x1_;
+      unsigned int            y1_;
+      unsigned int            x2_;
+      unsigned int            y2_;
+
+    public:
+      Region2D(unsigned int x1, unsigned int y1, unsigned int x2, unsigned int y2);
+      
+      virtual bool GetPixelMaskArea(unsigned int& x1, unsigned int& y1, unsigned int& x2, unsigned int& y2, const ParsedDicomFile& file, unsigned int frameIndex) const ORTHANC_OVERRIDE;
+    };
+
+    class Region3D : public BaseRegion
+    {
+      double             x1_;
+      double             y1_;
+      double             z1_;
+      double             x2_;
+      double             y2_;
+      double             z2_;
+
+    public:
+      Region3D(double x1, double y1, double z1, double x2, double y2, double z2);
+
+      virtual bool GetPixelMaskArea(unsigned int& x1, unsigned int& y1, unsigned int& x2, unsigned int& y2, const ParsedDicomFile& file, unsigned int frameIndex) const ORTHANC_OVERRIDE;
+    };
+
+  private:
+    std::list<BaseRegion*>   regions_;
+
+  public:
+    DicomPixelMasker();
+    
+    ~DicomPixelMasker();
+
+    void Apply(ParsedDicomFile& toModify);
+
+    void ParseRequest(const Json::Value& request);
+  };
+}
--- a/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -226,6 +226,28 @@
         fragment = dynamic_cast<DcmPixelItem*>(pixelSequence_->nextInContainer(fragment));
       }
     }
+
+    virtual uint8_t* GetRawFrameBuffer(unsigned int index)
+    {
+      if (index >= startFragment_.size())
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+
+      if (countFragments_[index] != 1)
+      {
+        throw OrthancException(ErrorCode_NotImplemented, "GetRawFrameBuffer is currently not implemented if there are more fragments than frames.");
+      }
+
+      DcmPixelItem* fragment = startFragment_[index];
+      uint8_t* content = NULL;
+      if (!fragment->getUint8Array(content).good() ||
+          content == NULL)
+      {
+        throw OrthancException(ErrorCode_InternalError);
+      }
+      return content;
+    }
   };
 
 
@@ -277,6 +299,12 @@
         memcpy(&frame[0], pixelData_ + index * frameSize_, frameSize_);
       }
     }
+
+    virtual uint8_t* GetRawFrameBuffer(unsigned int index)
+    {
+      return pixelData_ + index * frameSize_;
+    }
+
   };
 
 
@@ -308,6 +336,12 @@
         memcpy(&frame[0], reinterpret_cast<const uint8_t*>(&pixelData_[0]) + index * frameSize_, frameSize_);
       }
     }
+
+    virtual uint8_t* GetRawFrameBuffer(unsigned int index)
+    {
+      throw OrthancException(ErrorCode_NotImplemented);
+    }
+
   };
 
 
@@ -415,4 +449,20 @@
       throw OrthancException(ErrorCode_BadFileFormat, "Cannot access a raw frame");
     }
   }
+
+  uint8_t* DicomFrameIndex::GetRawFrameBuffer(unsigned int index)
+  {
+    if (index >= countFrames_)
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+    else if (index_.get() != NULL)
+    {
+      return index_->GetRawFrameBuffer(index);
+    }
+    else
+    {
+      throw OrthancException(ErrorCode_BadFileFormat, "Cannot access a raw frame");
+    }
+  }
 }
--- a/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.h	Sat Nov 15 12:27:24 2025 +0100
@@ -48,6 +48,8 @@
 
       virtual void GetRawFrame(std::string& frame,
                                unsigned int index) const = 0;
+
+      virtual uint8_t* GetRawFrameBuffer(unsigned int index) = 0;
     };
 
     class FragmentIndex;
@@ -69,5 +71,7 @@
                      unsigned int index) const;
 
     static unsigned int GetFramesCount(DcmDataset& dicom);
+
+    uint8_t* GetRawFrameBuffer(unsigned int index);
   };
 }
--- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -1819,6 +1819,46 @@
     }
   }
 
+  ImageAccessor* ParsedDicomFile::GetRawFrame(unsigned int frame)
+  {
+    E_TransferSyntax transferSyntax = GetDcmtkObjectConst().getDataset()->getCurrentXfer();
+    if (transferSyntax != EXS_LittleEndianImplicit && 
+        transferSyntax != EXS_BigEndianImplicit && 
+        transferSyntax != EXS_LittleEndianExplicit && 
+        transferSyntax != EXS_BigEndianExplicit)
+    {
+      throw OrthancException(ErrorCode_NotImplemented, "ParseDicomFile::GetRawFrame only works with uncompressed transfer syntaxes");
+    }
+
+    if (pimpl_->frameIndex_.get() == NULL)
+    {
+      assert(pimpl_->file_ != NULL &&
+             GetDcmtkObjectConst().getDataset() != NULL);
+      pimpl_->frameIndex_.reset(new DicomFrameIndex(*GetDcmtkObjectConst().getDataset()));
+    }
+
+    DicomMap m;
+    std::set<DicomTag> ignoreTagLength;
+    FromDcmtkBridge::ExtractDicomSummary(m, *GetDcmtkObjectConst().getDataset(), DicomImageInformation::GetUsefulTagLength(), ignoreTagLength);
+
+    DicomImageInformation info(m);
+    PixelFormat format;
+    
+    if (!info.ExtractPixelFormat(format, false))
+    {
+      LOG(WARNING) << "Unsupported DICOM image: " << info.GetBitsStored() 
+                   << "bpp, " << info.GetChannelCount() << " channels, " 
+                   << (info.IsSigned() ? "signed" : "unsigned")
+                   << (info.IsPlanar() ? ", planar, " : ", non-planar, ")
+                   << EnumerationToString(info.GetPhotometricInterpretation())
+                   << " photometric interpretation";
+      throw OrthancException(ErrorCode_NotImplemented);
+    }
+
+    std::unique_ptr<ImageAccessor> img(new ImageAccessor());
+    img->AssignWritable(format, info.GetWidth(), info.GetHeight(), info.GetWidth() * GetBytesPerPixel(format), pimpl_->frameIndex_->GetRawFrameBuffer(frame));
+    return img.release();
+  }
 
   static bool HasGenericGroupLength(const DicomPath& path)
   {
--- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h	Sat Nov 15 12:27:24 2025 +0100
@@ -316,6 +316,10 @@
     ImageAccessor* DecodeAllOverlays(int& originX,
                                      int& originY) const;
 
+    // Returns an image accessor to the raw frame only if the DicomFile is in an uncompressed TS.
+    // This enables modification of pixels data in place.
+    ImageAccessor* GetRawFrame(unsigned int frame);
+
     void InjectEmptyPixelData(ValueRepresentation vr);
 
     // Remove all the tags after pixel data
--- a/OrthancFramework/Sources/Enumerations.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/Enumerations.h	Sat Nov 15 12:27:24 2025 +0100
@@ -965,6 +965,12 @@
   DicomTransferSyntax GetTransferSyntax(const std::string& uid);
 
   ORTHANC_PUBLIC
+  bool IsRawTransferSyntax(DicomTransferSyntax syntax);
+
+  ORTHANC_PUBLIC
+  bool IsLossyTransferSyntax(DicomTransferSyntax syntax);
+
+  ORTHANC_PUBLIC
   const char* GetResourceTypeText(ResourceType type,
                                   bool isPlural,
                                   bool isUpperCase);
--- a/OrthancFramework/Sources/Enumerations_TransferSyntaxes.impl.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/Enumerations_TransferSyntaxes.impl.h	Sat Nov 15 12:27:24 2025 +0100
@@ -602,4 +602,276 @@
     target.insert(DicomTransferSyntax_RFC2557MimeEncapsulation);
     target.insert(DicomTransferSyntax_XML);
   }
+
+
+  bool IsLossyTransferSyntax(DicomTransferSyntax syntax)
+  {
+    switch (syntax)
+    {
+      case DicomTransferSyntax_LittleEndianImplicit:
+        return false;
+
+      case DicomTransferSyntax_LittleEndianExplicit:
+        return false;
+
+      case DicomTransferSyntax_DeflatedLittleEndianExplicit:
+        return false;
+
+      case DicomTransferSyntax_BigEndianExplicit:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess1:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess2_4:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess3_5:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess6_8:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess7_9:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess10_12:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess11_13:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess14:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess15:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess16_18:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess17_19:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess20_22:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess21_23:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess24_26:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess25_27:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess28:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess29:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess14SV1:
+        return false;
+
+      case DicomTransferSyntax_JPEGLSLossless:
+        return false;
+
+      case DicomTransferSyntax_JPEGLSLossy:
+        return true;
+
+      case DicomTransferSyntax_JPEG2000LosslessOnly:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000:
+        return true;
+
+      case DicomTransferSyntax_JPEG2000MulticomponentLosslessOnly:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000Multicomponent:
+        return true;
+
+      case DicomTransferSyntax_JPIPReferenced:
+        return false;
+
+      case DicomTransferSyntax_JPIPReferencedDeflate:
+        return false;
+
+      case DicomTransferSyntax_MPEG2MainProfileAtMainLevel:
+        return true;
+
+      case DicomTransferSyntax_MPEG2MainProfileAtHighLevel:
+        return true;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_1:
+        return true;
+
+      case DicomTransferSyntax_MPEG4BDcompatibleHighProfileLevel4_1:
+        return true;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_2_For2DVideo:
+        return true;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_2_For3DVideo:
+        return true;
+
+      case DicomTransferSyntax_MPEG4StereoHighProfileLevel4_2:
+        return true;
+
+      case DicomTransferSyntax_HEVCMainProfileLevel5_1:
+        return true;
+
+      case DicomTransferSyntax_HEVCMain10ProfileLevel5_1:
+        return true;
+
+      case DicomTransferSyntax_RLELossless:
+        return false;
+
+      case DicomTransferSyntax_RFC2557MimeEncapsulation:
+        return true;
+
+      case DicomTransferSyntax_XML:
+        return true;
+
+      default:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+  }
+
+
+  bool IsRawTransferSyntax(DicomTransferSyntax syntax)
+  {
+    switch (syntax)
+    {
+      case DicomTransferSyntax_LittleEndianImplicit:
+        return true;
+
+      case DicomTransferSyntax_LittleEndianExplicit:
+        return true;
+
+      case DicomTransferSyntax_DeflatedLittleEndianExplicit:
+        return false;
+
+      case DicomTransferSyntax_BigEndianExplicit:
+        return true;
+
+      case DicomTransferSyntax_JPEGProcess1:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess2_4:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess3_5:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess6_8:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess7_9:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess10_12:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess11_13:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess14:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess15:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess16_18:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess17_19:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess20_22:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess21_23:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess24_26:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess25_27:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess28:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess29:
+        return false;
+
+      case DicomTransferSyntax_JPEGProcess14SV1:
+        return false;
+
+      case DicomTransferSyntax_JPEGLSLossless:
+        return false;
+
+      case DicomTransferSyntax_JPEGLSLossy:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000LosslessOnly:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000MulticomponentLosslessOnly:
+        return false;
+
+      case DicomTransferSyntax_JPEG2000Multicomponent:
+        return false;
+
+      case DicomTransferSyntax_JPIPReferenced:
+        return false;
+
+      case DicomTransferSyntax_JPIPReferencedDeflate:
+        return false;
+
+      case DicomTransferSyntax_MPEG2MainProfileAtMainLevel:
+        return false;
+
+      case DicomTransferSyntax_MPEG2MainProfileAtHighLevel:
+        return false;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_1:
+        return false;
+
+      case DicomTransferSyntax_MPEG4BDcompatibleHighProfileLevel4_1:
+        return false;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_2_For2DVideo:
+        return false;
+
+      case DicomTransferSyntax_MPEG4HighProfileLevel4_2_For3DVideo:
+        return false;
+
+      case DicomTransferSyntax_MPEG4StereoHighProfileLevel4_2:
+        return false;
+
+      case DicomTransferSyntax_HEVCMainProfileLevel5_1:
+        return false;
+
+      case DicomTransferSyntax_HEVCMain10ProfileLevel5_1:
+        return false;
+
+      case DicomTransferSyntax_RLELossless:
+        return false;
+
+      case DicomTransferSyntax_RFC2557MimeEncapsulation:
+        return false;
+
+      case DicomTransferSyntax_XML:
+        return false;
+
+      default:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+  }
 }
--- a/OrthancFramework/Sources/Images/ImageProcessing.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/Images/ImageProcessing.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -2789,6 +2789,19 @@
         }
         break;
 
+      case PixelFormat_Grayscale16:
+        if (useRound)
+        {
+          SeparableConvolutionFloat<uint16_t, 1u, true>
+            (image, horizontal, horizontalAnchor, vertical, verticalAnchor, normalization);
+        }
+        else
+        {
+          SeparableConvolutionFloat<uint16_t, 1u, false>
+            (image, horizontal, horizontalAnchor, vertical, verticalAnchor, normalization);
+        }
+        break;
+
       case PixelFormat_RGB24:
         if (useRound)
         {
@@ -2822,6 +2835,14 @@
   }
 
 
+  void ImageProcessing::MeanFilter(ImageAccessor& image, size_t horizontalKernelWidth, size_t verticalKernelWidth)
+  {
+    std::vector<float> hKernel(horizontalKernelWidth, 1.0f);
+    std::vector<float> vKernel(verticalKernelWidth, 1.0f);
+    
+    SeparableConvolution(image, hKernel, horizontalKernelWidth / 2, vKernel, verticalKernelWidth / 2, false);
+  }
+
   void ImageProcessing::FitSize(ImageAccessor& target,
                                 const ImageAccessor& source)
   {
--- a/OrthancFramework/Sources/Images/ImageProcessing.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancFramework/Sources/Images/ImageProcessing.h	Sat Nov 15 12:27:24 2025 +0100
@@ -201,6 +201,8 @@
     static void SmoothGaussian5x5(ImageAccessor& image,
                                   bool useRound /* this is expensive */);
 
+    static void MeanFilter(ImageAccessor& image, size_t horizontalAverageWidth, size_t verticalAverageWidth);
+
     static void FitSize(ImageAccessor& target,
                         const ImageAccessor& source);
 
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -219,35 +219,9 @@
       ServerContext::DicomCacheLocker locker(context, id);
       modified.reset(locker.GetDicom().Clone(true));
     }
-    
-    modification.Apply(*modified);
-
-    if (transcode)
-    {
-      IDicomTranscoder::DicomImage source;
-      source.AcquireParsed(*modified);  // "modified" is invalid below this point
-      
-      IDicomTranscoder::DicomImage transcoded;
-
-      std::set<DicomTransferSyntax> s;
-      s.insert(targetSyntax);
-      
-      if (context.Transcode(transcoded, source, s, true, lossyQuality))
-      {      
-        call.GetOutput().AnswerBuffer(transcoded.GetBufferData(),
-                                      transcoded.GetBufferSize(), MimeType_Dicom);
-      }
-      else
-      {
-        throw OrthancException(ErrorCode_InternalError,
-                               "Cannot transcode to transfer syntax: " +
-                               std::string(GetTransferSyntaxUid(targetSyntax)));
-      }
-    }
-    else
-    {
-      modified->Answer(call.GetOutput());
-    }
+  
+    context.Modify(modified, modification, transcode, targetSyntax, lossyQuality, false /* keepSOPInstanceUidDuringLossyTranscoding*/);
+    modified->Answer(call.GetOutput());
   }
 
 
--- a/OrthancServer/Sources/ServerContext.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -50,6 +50,7 @@
 
 #include <dcmtk/dcmdata/dcfilefo.h>
 #include <dcmtk/dcmnet/dimse.h>
+#include <dcmtk/dcmdata/dcdeftag.h>
 #include <dcmtk/dcmdata/dcuid.h>        /* for variable dcmAllStorageSOPClassUIDs */
 
 #include <boost/regex.hpp>
@@ -2085,6 +2086,106 @@
     }
   }
 
+  void ServerContext::Modify(std::unique_ptr<ParsedDicomFile>& dicomFile, 
+                             DicomModification& modification,
+                             bool transcode,
+                             DicomTransferSyntax targetSyntax,
+                             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) &&
+        !IsRawTransferSyntax(currentTransferSyntax))
+    {
+      IDicomTranscoder::DicomImage source;
+      source.AcquireParsed(*dicomFile);  // "dicomFile" is invalid below this point
+
+      IDicomTranscoder::DicomImage transcoded;
+
+      std::set<DicomTransferSyntax> uncompressedTransferSyntax;
+      uncompressedTransferSyntax.insert(DicomTransferSyntax_LittleEndianExplicit);
+      Transcode(transcoded, source, uncompressedTransferSyntax, true);
+
+      dicomFile.reset(transcoded.ReleaseAsParsedDicomFile());
+
+      if (IsLossyTransferSyntax(currentTransferSyntax))
+      {
+        // 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, 
+        // let's 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;
+        targetSyntax = currentTransferSyntax;
+      }
+      
+    }
+
+    modification.Apply(*dicomFile);
+
+    if (transcode)
+    {
+      const std::string modifiedUid = IDicomTranscoder::GetSopInstanceUid(dicomFile->GetDcmtkObject());
+
+      IDicomTranscoder::DicomImage source;
+      source.AcquireParsed(*dicomFile);  // "dicomFile" is invalid below this point
+      
+      IDicomTranscoder::DicomImage transcoded;
+
+      std::set<DicomTransferSyntax> s;
+      s.insert(targetSyntax);
+
+      if (Transcode(transcoded, source, s, true, lossyQuality))
+      {      
+        dicomFile.reset(transcoded.ReleaseAsParsedDicomFile());
+
+        if (keepSOPInstanceUidDuringLossyTranscoding)
+        {
+          // Fix the SOP instance UID in order the preserve the
+          // references between instance UIDs in the DICOM hierarchy
+          // (the UID might have changed during this last transcoding step in the case of lossy transcoding)
+          if (dicomFile.get() == NULL ||
+              dicomFile->GetDcmtkObject().getDataset() == NULL ||
+              !dicomFile->GetDcmtkObject().getDataset()->putAndInsertString(
+                DCM_SOPInstanceUID, modifiedUid.c_str(), OFTrue /* replace */).good())
+          {
+            throw OrthancException(ErrorCode_InternalError);
+          }
+        }
+        return;
+      }
+      else
+      {
+        throw OrthancException(ErrorCode_InternalError,
+                               "Cannot transcode to transfer syntax " +
+                               std::string(GetTransferSyntaxUid(targetSyntax)));
+      }
+    }
+    // TODO-PIXEL-ANON: set forceModifiedSopInstanceUid if required
+  }
+
+
+
   const std::string& ServerContext::GetDeidentifiedContent(const DicomElement &element) const
   {
     static const std::string redactedContent = "*** POTENTIAL PHI ***";
--- a/OrthancServer/Sources/ServerContext.h	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Sat Nov 15 12:27:24 2025 +0100
@@ -604,6 +604,13 @@
                                     const std::string& attachmentId, // for the storage cache
                                     DicomTransferSyntax targetSyntax);
 
+    void Modify(std::unique_ptr<ParsedDicomFile>& toModify, 
+                DicomModification& modification,
+                bool transcode,
+                DicomTransferSyntax targetSyntax,
+                unsigned int lossyQuality,
+                bool keepSOPInstanceUidDuringLossyTranscoding);
+
     bool IsTranscodeDicomProtocol() const
     {
       return transcodeDicomProtocol_;
--- a/OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -92,7 +92,7 @@
 
     try
     {
-      modification_->Apply(*modified);
+      context_.Modify(modified, *modification_, false, DicomTransferSyntax_LittleEndianExplicit /* not used */, 100 /* not used */, false /*keepSOPInstanceUidDuringLossyTranscoding*/);
 
       std::unique_ptr<DicomInstanceToStore> toStore(DicomInstanceToStore::CreateFromParsedDicomFile(*modified));
       assert(origin_ == RequestOrigin_Lua);
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Sat Nov 15 11:49:39 2025 +0100
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Sat Nov 15 12:27:24 2025 +0100
@@ -238,7 +238,7 @@
     {
       boost::recursive_mutex::scoped_lock lock(mutex_);  // DicomModification object is not thread safe, we must protect it from here
 
-      modification_->Apply(*modified);
+      GetContext().Modify(modified, *modification_, transcode_, transferSyntax_, 100 /* not used */, true /* keepSOPInstanceUidDuringLossyTranscoding*/);  // TODO-PIXEL-ANON: get lossy quality from ???
 
       if (modification_->AreLabelsKept())
       {
@@ -252,36 +252,6 @@
 
     const std::string modifiedUid = IDicomTranscoder::GetSopInstanceUid(modified->GetDcmtkObject());
     
-    if (transcode_)
-    {
-      std::set<DicomTransferSyntax> syntaxes;
-      syntaxes.insert(transferSyntax_);
-
-      IDicomTranscoder::DicomImage source;
-      source.AcquireParsed(*modified);  // "modified" is invalid below this point
-      
-      IDicomTranscoder::DicomImage transcoded;
-      if (GetContext().Transcode(transcoded, source, syntaxes, true))
-      {
-        modified.reset(transcoded.ReleaseAsParsedDicomFile());
-
-        // Fix the SOP instance UID in order the preserve the
-        // references between instance UIDs in the DICOM hierarchy
-        // (the UID might have changed in the case of lossy transcoding)
-        if (modified.get() == NULL ||
-            modified->GetDcmtkObject().getDataset() == NULL ||
-            !modified->GetDcmtkObject().getDataset()->putAndInsertString(
-              DCM_SOPInstanceUID, modifiedUid.c_str(), OFTrue /* replace */).good())
-        {
-          throw OrthancException(ErrorCode_InternalError);
-        }
-      }
-      else
-      {
-        LOG(WARNING) << "Cannot transcode instance, keeping original transfer syntax: " << instance;
-        modified.reset(source.ReleaseAsParsedDicomFile());
-      }
-    }
 
     assert(modifiedUid == IDicomTranscoder::GetSopInstanceUid(modified->GetDcmtkObject()));
 
--- a/TODO	Sat Nov 15 11:49:39 2025 +0100
+++ b/TODO	Sat Nov 15 12:27:24 2025 +0100
@@ -462,3 +462,216 @@
 
 * 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" : "2D",             // area is defined by an area in pixel coordinates
+				"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" : "3D",                // area is defined by a volume in world coordinates
+				"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.2", "StudyDescription": "modified-all-slices"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 4000,
+            "RegionType" : "2D",
+            "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" : "2D",
+            "Origin": [150, 100],
+            "End": [400, 200],
+            "TargetInstances" : ["11e0b13a-fb44c3d5-819193d8-314b3bb5-4da13bc4", "372d70bb-886e6cc5-a89eefcc-405b2346-f4676bb2"]
+      }]
+    }
+}
+EOF
+
+# modify a few slices of one series of a study with a 3D Region
+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" : "3D",
+            "Origin": [-150.0, -300, -750],
+            "End": [150.0, -250, -1000]
+      }]
+    }
+}
+EOF
+
+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": "MeanFilter",
+            "FilterWidth": 30,
+            "RegionType" : "3D",
+            "Origin": [-150.0, -300, -750],
+            "End": [150.0, -250, -1000]
+      }]
+    }
+}
+EOF
+
+
+# modify all frames of a multiframe study (CARDIO)
+curl -X POST http://localhost:8043/studies/595df1a1-74fe920a-4b9e3509-826f17a3-762a2dc3/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.4", "StudyDescription": "modified-multi-frame"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "MeanFilter",
+            "FilterWidth": 30,
+            "RegionType" : "2D",
+            "Origin": [150, 100],
+            "End": [400, 200]
+      }]
+    }
+}
+EOF
+
+# modify all frames of a multiframe US study (Cine US, TS = .50)
+curl -X POST http://localhost:8043/studies/50f2961c-c35755eb-8762b05b-0e01cd97-dd8a294c/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.5", "StudyDescription": "modified-multi-frame-us"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "MeanFilter",
+            "FilterWidth": 30,
+            "RegionType" : "2D",
+            "Origin": [0, 0],
+            "End": [768, 45]
+      }]
+    }
+}
+EOF
+
+
+# modify some frames of a multiframe scintigraphy study
+curl -X POST http://localhost:8043/studies/ab67d5f8-95865506-8fb83c8b-93610651-ddce6e77/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.6", "StudyDescription": "modified-scinti"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 3000,
+            "RegionType" : "3D",
+            "Origin": [-150.0, -200, 200],
+            "End": [150.0, -150, 100]
+      }]
+    }
+}
+EOF
+
+# modify some frames of a multiframe scintigraphy series
+curl -X POST http://localhost:8043/series/08a23232-a61c3cb9-5cdb4518-b725cd5d-820ee1f6/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.6", "StudyDescription": "modified-scinti"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 3000,
+            "RegionType" : "3D",
+            "Origin": [-150.0, -200, 200],
+            "End": [150.0, -150, 100]
+      }]
+    }
+}
+EOF
+
+
+
+# modify some frames of a multiframe PET-CT study
+curl -X POST http://localhost:8043/studies/890e1167-55ad171a-7721ffec-db91e2c1-700778c0/modify \
+--data-binary @- << EOF
+{
+    "Replace" : {"StudyInstanceUID": "1.2.7", "StudyDescription": "modified-pet-ct"},
+    "Force": true,
+    "MaskPixelData": {
+        "Regions": [{
+            "MaskType": "Fill",
+            "FillValue": 3000,
+            "RegionType" : "3D",
+            "Origin": [-150.0, -100, -85],
+            "End": [150.0, 100, -250]
+      }]
+    }
+}
+EOF
+