changeset 6032:c76b1b2ee57e pixel-anon

very first proto of pixel-anon: only tested on /instances/{id}/anonymize
author Alain Mazy <am@orthanc.team>
date Thu, 06 Mar 2025 19:10:32 +0100
parents 913ccc943851
children 6ad530603d23
files OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake OrthancFramework/Sources/DicomParsing/DicomModification.cpp OrthancFramework/Sources/DicomParsing/DicomModification.h OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.cpp OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.h OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h OrthancFramework/Sources/Images/ImageProcessing.cpp OrthancFramework/Sources/Images/ImageProcessing.h OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp OrthancServer/Sources/ServerContext.cpp OrthancServer/Sources/ServerContext.h OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp
diffstat 16 files changed, 453 insertions(+), 63 deletions(-) [+]
line wrap: on
line diff
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Thu Mar 06 19:10:32 2025 +0100
@@ -540,6 +540,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/Sources/DicomParsing/DicomModification.cpp	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp	Thu Mar 06 19:10:32 2025 +0100
@@ -1145,6 +1145,12 @@
       toModify.ReplacePath((*it)->GetPath(), (*it)->GetValue(), true /* decode data URI scheme */,
                            DicomReplaceMode_InsertIfAbsent, privateCreator_);
     }
+
+    // (9) New in Orthanc 1.X.X: Apply pixel modifications
+    if (pixelMasker_ != NULL)
+    {
+      pixelMasker_->Apply(toModify);
+    }
   }
 
   void DicomModification::SetAllowManualIdentifiers(bool check)
@@ -1379,7 +1385,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);
@@ -1393,7 +1399,7 @@
     {
       if (request["DicomVersion"].type() != Json::stringValue)
       {
-        throw OrthancException(ErrorCode_BadFileFormat);
+        throw OrthancException(ErrorCode_BadFileFormat, "DicomVersion should be a string");
       }
       else
       {
@@ -1435,6 +1441,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)
@@ -1896,4 +1909,9 @@
       target.insert(it->first);
     }
   }
+
+  bool DicomModification::RequiresUncompressedTransferSyntax() const
+  {
+    return pixelMasker_ != NULL;
+  }
 }
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.h	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomModification.h	Thu Mar 06 19:10:32 2025 +0100
@@ -25,6 +25,7 @@
 #pragma once
 
 #include "ParsedDicomFile.h"
+#include "DicomPixelMasker.h"
 
 #include <list>
 
@@ -154,6 +155,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: check ownership & serialization
+
     std::string MapDicomIdentifier(const std::string& original,
                                    ResourceType level);
 
@@ -268,5 +272,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	Thu Mar 06 19:10:32 2025 +0100
@@ -0,0 +1,142 @@
+/**
+ * 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"
+
+
+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_PIXELS = "Pixels";
+  static const char* KEY_REGION_BOUNDING_BOX = "BoundingBox";
+  static const char* KEY_ORIGIN = "Origin";
+  static const char* KEY_END = "End";
+  static const char* KEY_TARGET_SERIES = "TargetSeries";
+
+  DicomPixelMasker::DicomPixelMasker()
+  {
+  }
+
+  void DicomPixelMasker::Apply(ParsedDicomFile& toModify)
+  {
+    for (std::list<Region>::const_iterator r = regions_.begin(); r != regions_.end(); ++r)
+    {
+      ImageAccessor imageRegion;
+      toModify.GetRawFrame(0)->GetRegion(imageRegion, r->x_, r->y_, r->width_, r->height_);
+
+      if (r->mode_ == DicomPixelMaskerMode_MeanFilter)
+      {
+        ImageProcessing::MeanFilter(imageRegion, r->filterWidth_, r->filterWidth_);
+      }
+      else if (r->mode_ == DicomPixelMaskerMode_Fill)
+      {
+        ImageProcessing::Set(imageRegion, r->fillValue_);
+      }
+    }
+  }
+
+  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];
+          Region region;
+
+          if (regionJson.isMember(KEY_MASK_TYPE) && regionJson[KEY_MASK_TYPE].isString())
+          {
+            if (regionJson[KEY_MASK_TYPE].asString() == KEY_MASK_TYPE_FILL)
+            {
+              region.mode_ = DicomPixelMaskerMode_Fill;
+
+              if (regionJson.isMember(KEY_FILL_VALUE) && regionJson[KEY_FILL_VALUE].isInt())
+              {
+                region.fillValue_ = regionJson[KEY_FILL_VALUE].asInt();
+              }
+            }
+            else if (regionJson[KEY_MASK_TYPE].asString() == KEY_MASK_TYPE_MEAN_FILTER)
+            {
+              region.mode_ = DicomPixelMaskerMode_MeanFilter;
+
+              if (regionJson.isMember(KEY_FILTER_WIDTH) && regionJson[KEY_FILTER_WIDTH].isUInt())
+              {
+                region.filterWidth_ = 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())
+          {
+            SerializationToolbox::ReadListOfStrings(region.targetSeries_, regionJson, KEY_TARGET_SERIES);
+          }
+
+          if (regionJson.isMember(KEY_REGION_TYPE) && regionJson[KEY_REGION_TYPE].asString() == KEY_REGION_PIXELS)
+          {
+            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)
+            {
+              region.x_ = regionJson[KEY_ORIGIN][0].asUInt();
+              region.y_ = regionJson[KEY_ORIGIN][1].asUInt();
+              region.width_ = regionJson[KEY_END][0].asUInt() - region.x_;
+              region.height_ = regionJson[KEY_END][1].asUInt() - region.y_;
+              
+              regions_.push_back(region);
+            }
+          } 
+          else if (regionJson.isMember(KEY_REGION_TYPE) && regionJson[KEY_REGION_TYPE].asString() == KEY_REGION_BOUNDING_BOX)
+          {
+            // TODO
+            throw OrthancException(ErrorCode_NotImplemented);
+          }
+        }
+
+      }
+
+      // TODO: support multiple series + move this 
+    }
+
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h	Thu Mar 06 19:10:32 2025 +0100
@@ -0,0 +1,76 @@
+/**
+ * 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 <list>
+
+
+namespace Orthanc
+{
+  enum DicomPixelMaskerMode
+  {
+    DicomPixelMaskerMode_Fill,
+    DicomPixelMaskerMode_MeanFilter
+  };
+
+  class ORTHANC_PUBLIC DicomPixelMasker : public boost::noncopyable
+  {
+    struct Region
+    {
+      unsigned int            x_;
+      unsigned int            y_;
+      unsigned int            width_;
+      unsigned int            height_;
+      DicomPixelMaskerMode    mode_;
+      int32_t                 fillValue_;  // pixel value
+      uint32_t                filterWidth_;  // filter width
+      std::list<std::string>  targetSeries_;
+
+      Region() :
+        x_(0),
+        y_(0),
+        width_(0),
+        height_(0),
+        mode_(DicomPixelMaskerMode_Fill),
+        fillValue_(0),
+        filterWidth_(0)
+      {
+      }
+    };
+
+  private:
+    std::list<Region>   regions_;
+
+  public:
+    DicomPixelMasker();
+
+    void Apply(ParsedDicomFile& toModify);
+
+    void ParseRequest(const Json::Value& request);
+  };
+}
--- a/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.cpp	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.cpp	Thu Mar 06 19:10:32 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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/Internals/DicomFrameIndex.h	Thu Mar 06 19:10:32 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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Thu Mar 06 19:10:32 2025 +0100
@@ -1805,6 +1805,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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h	Thu Mar 06 19:10:32 2025 +0100
@@ -313,6 +313,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/Images/ImageProcessing.cpp	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/Images/ImageProcessing.cpp	Thu Mar 06 19:10:32 2025 +0100
@@ -2756,6 +2756,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)
         {
@@ -2789,6 +2802,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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancFramework/Sources/Images/ImageProcessing.h	Thu Mar 06 19:10:32 2025 +0100
@@ -200,6 +200,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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Thu Mar 06 19:10:32 2025 +0100
@@ -218,35 +218,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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Thu Mar 06 19:10:32 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>
@@ -2012,6 +2013,80 @@
     }
   }
 
+  void ServerContext::Modify(std::unique_ptr<ParsedDicomFile>& dicomFile, 
+                             DicomModification& modification,
+                             bool transcode,
+                             DicomTransferSyntax targetSyntax,
+                             unsigned int lossyQuality,
+                             bool keepSOPInstanceUidDuringLossyTranscoding)
+  {
+    // do we need to transcode before ?
+    DicomTransferSyntax currentTransferSyntax;
+    if (modification.RequiresUncompressedTransferSyntax() && 
+        dicomFile->LookupTransferSyntax(currentTransferSyntax) &&
+        currentTransferSyntax > DicomTransferSyntax_BigEndianExplicit)
+    {
+      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);
+      
+      if (!transcode) // if we had to change the TS for the modification, we need to restore the original TS afterwards
+      {
+        transcode = true;
+        targetSyntax = currentTransferSyntax;
+      }
+      dicomFile.reset(transcoded.ReleaseAsParsedDicomFile());
+    }
+
+    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)));
+      }
+    }
+  }
+
+
+
   const std::string& ServerContext::GetDeidentifiedContent(const DicomElement &element) const
   {
     static const std::string redactedContent = "*** POTENTIAL PHI ***";
--- a/OrthancServer/Sources/ServerContext.h	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Thu Mar 06 19:10:32 2025 +0100
@@ -590,6 +590,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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp	Thu Mar 06 19:10:32 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	Tue Feb 25 19:19:40 2025 +0100
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Thu Mar 06 19:10:32 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()));