Mercurial > hg > orthanc
changeset 6040:100a0371fc85 pixel-anon
pixel anon: 3D regions
| author | Alain Mazy <am@orthanc.team> |
|---|---|
| date | Thu, 13 Mar 2025 10:09:50 +0100 |
| parents | 4742b4fe824b |
| children | 0a60d2d482d4 |
| files | OrthancFramework/Sources/DicomParsing/DicomModification.cpp OrthancFramework/Sources/DicomParsing/DicomModification.h OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h TODO |
| diffstat | 5 files changed, 352 insertions(+), 78 deletions(-) [+] |
line wrap: on
line diff
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.cpp Wed Mar 12 09:59:38 2025 +0100 +++ b/OrthancFramework/Sources/DicomParsing/DicomModification.cpp Thu Mar 13 10:09:50 2025 +0100 @@ -31,6 +31,7 @@ #include "../SerializationToolbox.h" #include "FromDcmtkBridge.h" #include "ITagVisitor.h" +#include "DicomPixelMasker.h" #include <memory> // For std::unique_ptr
--- a/OrthancFramework/Sources/DicomParsing/DicomModification.h Wed Mar 12 09:59:38 2025 +0100 +++ b/OrthancFramework/Sources/DicomParsing/DicomModification.h Thu Mar 13 10:09:50 2025 +0100 @@ -25,13 +25,14 @@ #pragma once #include "ParsedDicomFile.h" -#include "DicomPixelMasker.h" #include <list> namespace Orthanc { + class DicomPixelMasker; + class ORTHANC_PUBLIC DicomModification : public boost::noncopyable { /** @@ -156,7 +157,7 @@ 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::unique_ptr<DicomPixelMasker> pixelMasker_; // TODO-PIXEL-ANON: check ownership & serialization std::string MapDicomIdentifier(const std::string& original, ResourceType level);
--- a/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp Wed Mar 12 09:59:38 2025 +0100 +++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.cpp Thu Mar 13 10:09:50 2025 +0100 @@ -39,8 +39,8 @@ 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_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"; @@ -50,38 +50,181 @@ { } - void DicomPixelMasker::Apply(ParsedDicomFile& toModify) + 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) { - DicomInstanceHasher hasher = toModify.GetHasher(); + } + + 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(); - for (std::list<Region>::const_iterator r = regions_.begin(); r != regions_.end(); ++r) + 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 ParsedDicomFile& file, const DicomTag& tag, size_t expectedSize) + { + target.clear(); + + std::string str; + if (!file.GetTagValue(str, tag)) + { + throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, missing tag" + tag.Format()); + } + + std::vector<std::string> strVector; + Toolbox::SplitString(strVector, str, '\\'); + + 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) { - if (r->targetSeries_.size() > 0 && r->targetSeries_.find(seriesId) == r->targetSeries_.end()) + try + { + target.push_back(boost::lexical_cast<double>(strVector[i])); + } + catch (boost::bad_lexical_cast&) { - continue; + throw OrthancException(ErrorCode_InexistentTag, "Unable to perform 3D -> 2D conversion, tag " + tag.Format() + " contains invalid value " + strVector[i]); + } + } + } + + 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)) + { + std::vector<double> imagePositionPatient; + std::vector<double> imageOrientationPatient; + std::vector<double> pixelSpacing; + + GetDoubleVector(imagePositionPatient, file, DICOM_TAG_IMAGE_POSITION_PATIENT, 3); + GetDoubleVector(imageOrientationPatient, file, DICOM_TAG_IMAGE_ORIENTATION_PATIENT, 6); + GetDoubleVector(pixelSpacing, file, DICOM_TAG_PIXEL_SPACING, 2); + + // 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 (imagePositionPatient[2] < std::min(z1_, z2_) || + imagePositionPatient[2] > std::max(z1_, z2_)) + { + return false; + } + + double deltaX1 = x1_ - imagePositionPatient[0]; + double deltaY1 = y1_ - imagePositionPatient[1]; + double deltaZ1 = z1_ - imagePositionPatient[2]; + double deltaX2 = x2_ - imagePositionPatient[0]; + double deltaY2 = y2_ - imagePositionPatient[1]; + double deltaZ2 = z2_ - imagePositionPatient[2]; + + 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"); } - if (r->targetInstances_.size() > 0 && r->targetInstances_.find(instanceId) == r->targetInstances_.end()) - { - continue; - } + // 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) { - // TODO-PIXEL-ANON: skip unwanted frames + unsigned int x1, y1, x2, y2; - ImageAccessor imageRegion; - toModify.GetRawFrame(i)->GetRegion(imageRegion, r->x_, r->y_, r->width_, r->height_); + if (r->GetPixelMaskArea(x1, y1, x2, y2, toModify, i)) + { + ImageAccessor imageRegion; + toModify.GetRawFrame(i)->GetRegion(imageRegion, x1, y1, x2 - x1, y2 - y1); - if (r->mode_ == DicomPixelMaskerMode_MeanFilter) - { - ImageProcessing::MeanFilter(imageRegion, r->filterWidth_, r->filterWidth_); - } - else if (r->mode_ == DicomPixelMaskerMode_Fill) - { - ImageProcessing::Set(imageRegion, r->fillValue_); + if (r->GetMode() == DicomPixelMaskerMode_MeanFilter) + { + ImageProcessing::MeanFilter(imageRegion, r->GetFilterWidth(), r->GetFilterWidth()); + } + else if (r->GetMode() == DicomPixelMaskerMode_Fill) + { + ImageProcessing::Set(imageRegion, r->GetFillValue()); + } } } } @@ -100,26 +243,68 @@ for (Json::ArrayIndex i = 0; i < regionsJson.size(); ++i) { const Json::Value& regionJson = regionsJson[i]; - Region region; + + 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) { - region.mode_ = DicomPixelMaskerMode_Fill; - if (regionJson.isMember(KEY_FILL_VALUE) && regionJson[KEY_FILL_VALUE].isInt()) { - region.fillValue_ = regionJson[KEY_FILL_VALUE].asInt(); + region->SetFillValue(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(); + region->SetMeanFilter(regionJson[KEY_FILTER_WIDTH].asUInt()); } } else @@ -130,37 +315,21 @@ if (regionJson.isMember(KEY_TARGET_SERIES) && regionJson[KEY_TARGET_SERIES].isArray()) { - SerializationToolbox::ReadSetOfStrings(region.targetSeries_, regionJson, KEY_TARGET_SERIES); + 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()) { - SerializationToolbox::ReadSetOfStrings(region.targetInstances_, regionJson, KEY_TARGET_INSTANCES); + std::set<std::string> targetInstances; + SerializationToolbox::ReadSetOfStrings(targetInstances, regionJson, KEY_TARGET_INSTANCES); + region->SetTargetInstances(targetInstances); } - 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); - } + regions_.push_back(region.release()); } - } - - // TODO: support multiple series + move this } }
--- a/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h Wed Mar 12 09:59:38 2025 +0100 +++ b/OrthancFramework/Sources/DicomParsing/DicomPixelMasker.h Thu Mar 13 10:09:50 2025 +0100 @@ -35,40 +35,108 @@ enum DicomPixelMaskerMode { DicomPixelMaskerMode_Fill, - DicomPixelMaskerMode_MeanFilter + DicomPixelMaskerMode_MeanFilter, + + DicomPixelMaskerMode_Undefined }; class ORTHANC_PUBLIC DicomPixelMasker : public boost::noncopyable { - struct Region + class BaseRegion { - 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::set<std::string> targetSeries_; - std::set<std::string> targetInstances_; + 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_; + } - Region() : - x_(0), - y_(0), - width_(0), - height_(0), - mode_(DicomPixelMaskerMode_Fill), - fillValue_(0), - filterWidth_(0) + 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<Region> regions_; + std::list<BaseRegion*> regions_; public: DicomPixelMasker(); + + ~DicomPixelMasker(); void Apply(ParsedDicomFile& toModify);
--- a/TODO Wed Mar 12 09:59:38 2025 +0100 +++ b/TODO Thu Mar 13 10:09:50 2025 +0100 @@ -434,7 +434,7 @@ { "MaskType": "MeanFilter", "FilterWidth": 20, - "RegionType" : "Pixels", + "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 @@ -446,7 +446,7 @@ { "MaskType": "Fill", "FillValue": 0, - "RegionType" : "BoundingBox", // area is defined by a volume in world coordinates (TODO-PIXEL-ANON) + "RegionType" : "3D", // 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 ! @@ -471,7 +471,7 @@ "Regions": [{ "MaskType": "Fill", "FillValue": 4000, - "RegionType" : "Pixels", + "RegionType" : "2D", "Origin": [150, 100], "End": [400, 200], "TargetSeries" : ["d5f6c1a2-d6f2f01a-a3cc4e8b-424476dc-f34b0cd1"] @@ -491,7 +491,7 @@ "Regions": [{ "MaskType": "Fill", "FillValue": 4000, - "RegionType" : "Pixels", + "RegionType" : "2D", "Origin": [150, 100], "End": [400, 200], "TargetInstances" : ["11e0b13a-fb44c3d5-819193d8-314b3bb5-4da13bc4", "372d70bb-886e6cc5-a89eefcc-405b2346-f4676bb2"] @@ -500,6 +500,41 @@ } 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 \ @@ -511,7 +546,7 @@ "Regions": [{ "MaskType": "MeanFilter", "FilterWidth": 30, - "RegionType" : "Pixels", + "RegionType" : "2D", "Origin": [150, 100], "End": [400, 200] }] @@ -529,7 +564,7 @@ "Regions": [{ "MaskType": "MeanFilter", "FilterWidth": 30, - "RegionType" : "Pixels", + "RegionType" : "2D", "Origin": [0, 0], "End": [768, 45] }]
