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