Mercurial > hg > orthanc-dicomweb
changeset 696:6e165e40b1df
/rendered for video + use tools/find from 1.12.5 to simplify code and optimize SQL queries
author | Alain Mazy <am@orthanc.team> |
---|---|
date | Thu, 17 Apr 2025 09:59:46 +0200 (4 weeks ago) |
parents | ad41d16f36b1 |
children | a3801ea80734 |
files | NEWS Plugin/Plugin.cpp Plugin/WadoRs.cpp Plugin/WadoRs.h Plugin/WadoRsRetrieveFrames.cpp Plugin/WadoRsRetrieveRendered.cpp Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h TODO |
diffstat | 9 files changed, 236 insertions(+), 103 deletions(-) [+] |
line wrap: on
line diff
--- a/NEWS Fri Jan 17 12:24:54 2025 +0100 +++ b/NEWS Thu Apr 17 09:59:46 2025 +0200 @@ -1,10 +1,19 @@ Pending changes in the mainline =============================== +=> Minimum Orthanc version: 1.12.5 <= +=> Minimum SDK version: 1.12.1 <= + +* If calling /rendered route on a video, the plugin will now return the video file (MP4 or ...). + This notably enables display of videos in OHIF 3.10.1. + Version 1.18 (2024-12-18) ========================= +=> Minimum Orthanc version: 1.11.0 <= +=> Minimum SDK version: 1.12.1 <= + * Added a "Server" entry in the DICOMweb job content * Fixed parsing of numerical values in QIDO-RS response that prevented, among other, the retrieval of "NumberOfStudyRelatedInstances", "NumberOfStudyRelatedSeries",...
--- a/Plugin/Plugin.cpp Fri Jan 17 12:24:54 2025 +0100 +++ b/Plugin/Plugin.cpp Thu Apr 17 09:59:46 2025 +0200 @@ -37,9 +37,10 @@ #include <boost/algorithm/string/predicate.hpp> +// we use "ResponseContent" in tools/find -> we need 1.12.5 #define ORTHANC_CORE_MINIMAL_MAJOR 1 -#define ORTHANC_CORE_MINIMAL_MINOR 11 -#define ORTHANC_CORE_MINIMAL_REVISION 0 +#define ORTHANC_CORE_MINIMAL_MINOR 12 +#define ORTHANC_CORE_MINIMAL_REVISION 5 static const char* const HAS_DELETE = "HasDelete"; static const char* const SYSTEM_CAPABILITIES = "Capabilities"; @@ -486,7 +487,7 @@ if (hasExtendedFind) { LOG(INFO) << "Orthanc supports ExtendedFind."; - SetPluginCanUseExtendedFile(true); + SetPluginCanUseExtendedFind(true); } else {
--- a/Plugin/WadoRs.cpp Fri Jan 17 12:24:54 2025 +0100 +++ b/Plugin/WadoRs.cpp Thu Apr 17 09:59:46 2025 +0200 @@ -53,12 +53,12 @@ static bool pluginCanUseExtendedFind_ = false; static bool isSystemReadOnly_ = false; -void SetPluginCanUseExtendedFile(bool enable) +void SetPluginCanUseExtendedFind(bool enable) { pluginCanUseExtendedFind_ = enable; } -bool CanUseExtendedFile() +bool CanUseExtendedFind() { return pluginCanUseExtendedFind_; } @@ -939,11 +939,13 @@ bool LocateResource(OrthancPluginRestOutput* output, std::string& orthancId, + std::map<std::string, std::string>& metadata, const std::string& studyInstanceUid, const std::string& seriesInstanceUid, const std::string& sopInstanceUid, const std::string& level, - const OrthancPluginHttpRequest* request) + const OrthancPluginHttpRequest* request, + bool firstResourceOnly) { OrthancPluginContext* context = OrthancPlugins::GetGlobalContext(); @@ -969,8 +971,15 @@ payloadQuery["SeriesInstanceUID"] = seriesInstanceUid; } + if (firstResourceOnly) + { + payload["Limit"] = 1; + } + payloadQuery["StudyInstanceUID"] = studyInstanceUid; payload["Query"] = payloadQuery; + payload["ResponseContent"] = Json::arrayValue; + payload["ResponseContent"].append("Metadata"); std::map<std::string, std::string> httpHeaders; OrthancPlugins::GetHttpHeaders(httpHeaders, request); @@ -992,7 +1001,14 @@ throw Orthanc::OrthancException(Orthanc::ErrorCode_InexistentItem, "Multiple " + level + " found for WADO-RS: " + studyInstanceUid + "/" + seriesInstanceUid + "/" + sopInstanceUid); } - orthancId = resources[0].asString(); + orthancId = resources[0]["ID"].asString(); + Json::Value::Members metadataMembers = resources[0]["Metadata"].getMemberNames(); + + for (size_t i = 0; i < metadataMembers.size(); ++i) + { + metadata[metadataMembers[i]] = resources[0]["Metadata"][metadataMembers[i]].asString(); + } + return true; } } @@ -1000,64 +1016,115 @@ bool LocateStudy(OrthancPluginRestOutput* output, - std::string& orthancId, + std::string& studyOrthancId, std::string& studyInstanceUid, const OrthancPluginHttpRequest* request) { std::string sopInstanceUid; std::string seriesInstanceUid; + std::map<std::string, std::string> metadata; studyInstanceUid = request->groups[0]; return LocateResource(output, - orthancId, + studyOrthancId, + metadata, studyInstanceUid, seriesInstanceUid, sopInstanceUid, "Study", - request); + request, + false); } bool LocateSeries(OrthancPluginRestOutput* output, - std::string& orthancId, + std::string& seriesOrthancId, std::string& studyInstanceUid, std::string& seriesInstanceUid, const OrthancPluginHttpRequest* request) { std::string sopInstanceUid; + std::map<std::string, std::string> metadata; studyInstanceUid = request->groups[0]; seriesInstanceUid = request->groups[1]; return LocateResource(output, - orthancId, + seriesOrthancId, + metadata, studyInstanceUid, seriesInstanceUid, sopInstanceUid, "Series", - request); + request, + false); } bool LocateInstance(OrthancPluginRestOutput* output, - std::string& orthancId, + std::string& instanceOrthancId, std::string& studyInstanceUid, std::string& seriesInstanceUid, std::string& sopInstanceUid, + std::string& transferSyntaxMetadata, const OrthancPluginHttpRequest* request) { + std::map<std::string, std::string> metadata; studyInstanceUid = request->groups[0]; seriesInstanceUid = request->groups[1]; sopInstanceUid = request->groups[2]; - return LocateResource(output, - orthancId, - studyInstanceUid, - seriesInstanceUid, - sopInstanceUid, - "Instance", - request); + bool ret = LocateResource(output, + instanceOrthancId, + metadata, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid, + "Instance", + request, + false); + + if (ret && metadata.find("TransferSyntax") != metadata.end()) + { + transferSyntaxMetadata = metadata["TransferSyntax"]; + } + + return ret; } +bool LocateOneInstance(OrthancPluginRestOutput* output, + std::string& instanceOrthancId, + std::string& studyInstanceUid, + std::string& seriesInstanceUid, + std::string& transferSyntaxMetadata, + const OrthancPluginHttpRequest* request) +{ + std::string sopInstanceUid; + std::map<std::string, std::string> metadata; + + studyInstanceUid = request->groups[0]; + + if (request->groupsCount > 1) + { + seriesInstanceUid = request->groups[1]; + } + + bool ret = LocateResource(output, + instanceOrthancId, + metadata, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid, + "Instance", + request, + false); + + if (ret && metadata.find("TransferSyntax") != metadata.end()) + { + transferSyntaxMetadata = metadata["TransferSyntax"]; + } + + return ret; +} void RetrieveDicomStudy(OrthancPluginRestOutput* output, const char* url, @@ -1099,12 +1166,13 @@ const OrthancPluginHttpRequest* request) { bool transcode; + std::string transferSyntax; Orthanc::DicomTransferSyntax targetSyntax; AcceptMultipartDicom(transcode, targetSyntax, request); std::string orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid; - if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, request)) + if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, transferSyntax, request)) { AnswerListOfDicomInstances(output, Orthanc::ResourceType_Instance, orthancId, transcode, targetSyntax); } @@ -1384,7 +1452,7 @@ ChildrenMainDicomMaps instancesDicomMaps; std::string seriesDicomUid; - if (CanUseExtendedFile()) // in this case, /series/.../instances?full has been optimized to minimize the SQL queries + if (CanUseExtendedFind()) // in this case, /series/.../instances?full has been optimized to minimize the SQL queries { GetChildrenMainDicomTags(instancesDicomMaps, seriesDicomUid, Orthanc::ResourceType_Series, seriesOrthancId); for (ChildrenMainDicomMaps::const_iterator it = instancesDicomMaps.begin(); it != instancesDicomMaps.end(); ++it) @@ -1410,7 +1478,7 @@ instancesWorkers.push_back(boost::shared_ptr<boost::thread>(new boost::thread(InstanceWorkerThread, threadData))); } - if (CanUseExtendedFile()) // we must correct the bulkRoot + if (CanUseExtendedFind()) // we must correct the bulkRoot { for (ChildrenMainDicomMaps::const_iterator i = instancesDicomMaps.begin(); i != instancesDicomMaps.end(); ++i) { @@ -1720,12 +1788,14 @@ const OrthancPluginHttpRequest* request) { bool isXml; + std::string transferSyntax; + AcceptMetadata(request, isXml); MainDicomTagsCache cache; std::string orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid; - if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, request)) + if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, transferSyntax, request)) { OrthancPlugins::DicomWebFormatter::HttpWriter writer(output, isXml); WriteInstanceMetadata(writer, OrthancPlugins::MetadataMode_Full, cache, orthancId, studyInstanceUid, @@ -1740,12 +1810,13 @@ const OrthancPluginHttpRequest* request) { OrthancPluginContext* context = OrthancPlugins::GetGlobalContext(); + std::string transferSyntax; AcceptBulkData(request); std::string orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid; OrthancPlugins::MemoryBuffer content; - if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, request) && + if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, transferSyntax, request) && content.RestApiGet("/instances/" + orthancId + "/file", false)) { std::string bulk(request->groups[3]);
--- a/Plugin/WadoRs.h Fri Jan 17 12:24:54 2025 +0100 +++ b/Plugin/WadoRs.h Thu Apr 17 09:59:46 2025 +0200 @@ -27,23 +27,31 @@ bool LocateStudy(OrthancPluginRestOutput* output, - std::string& uri, + std::string& studyOrthancId, std::string& studyInstanceUid, const OrthancPluginHttpRequest* request); bool LocateSeries(OrthancPluginRestOutput* output, - std::string& uri, + std::string& seriesOrthancId, std::string& studyInstanceUid, std::string& seriesInstanceUid, const OrthancPluginHttpRequest* request); bool LocateInstance(OrthancPluginRestOutput* output, - std::string& uri, + std::string& instanceOrthancId, std::string& studyInstanceUid, std::string& seriesInstanceUid, std::string& sopInstanceUid, + std::string& transferSyntaxMetadata, const OrthancPluginHttpRequest* request); +bool LocateOneInstance(OrthancPluginRestOutput* output, + std::string& instanceOrthancId, + std::string& studyInstanceUid, + std::string& seriesInstanceUid, + std::string& transferSyntaxMetadata, + const OrthancPluginHttpRequest* request); + void RetrieveDicomStudy(OrthancPluginRestOutput* output, const char* url, const OrthancPluginHttpRequest* request); @@ -104,6 +112,6 @@ void SetPluginCanDownloadTranscodedFile(bool enable); -void SetPluginCanUseExtendedFile(bool enable); +void SetPluginCanUseExtendedFind(bool enable); void SetSystemIsReadOnly(bool isReadOnly); \ No newline at end of file
--- a/Plugin/WadoRsRetrieveFrames.cpp Fri Jan 17 12:24:54 2025 +0100 +++ b/Plugin/WadoRsRetrieveFrames.cpp Thu Apr 17 09:59:46 2025 +0200 @@ -476,7 +476,9 @@ std::list<unsigned int>& frames) { std::string orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid; - if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, request)) + std::string transferSyntax; + + if (LocateInstance(output, orthancId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, transferSyntax, request)) { OrthancPlugins::MemoryBuffer content; Orthanc::DicomTransferSyntax currentSyntax; @@ -500,15 +502,9 @@ } } - std::string currentSyntaxString; - if (!OrthancPlugins::RestApiGetString(currentSyntaxString, "/instances/" + orthancId + "/metadata/TransferSyntax", false)) + if (!Orthanc::LookupTransferSyntax(currentSyntax, transferSyntax)) { - throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMweb: Unable to get TransferSyntax for instance " + orthancId); - } - - if (!Orthanc::LookupTransferSyntax(currentSyntax, currentSyntaxString)) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Unknown transfer syntax: " + currentSyntaxString); + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Unknown transfer syntax: " + transferSyntax); } Orthanc::DicomTransferSyntax targetSyntax = currentSyntax; @@ -518,7 +514,7 @@ // note: these 2 syntaxes are not supposed to be used in retrieve frames // according to https://dicom.nema.org/MEDICAL/dicom/2019a/output/chtml/part18/chapter_6.html#table_6.1.1.8-3b // "The Implicit VR Little Endian (1.2.840.10008.1.2), and Explicit VR Big Endian (1.2.840.10008.1.2.2) transfer syntaxes shall not be used with Web Services." - LOG(INFO) << "The file is in a transfer syntax " << currentSyntaxString << " that is not allowed by the DICOMweb standard -> it will be transcoded to Little Endian Explicit"; + LOG(INFO) << "The file is in a transfer syntax " << transferSyntax << " that is not allowed by the DICOMweb standard -> it will be transcoded to Little Endian Explicit"; targetSyntax = Orthanc::DicomTransferSyntax_LittleEndianExplicit; }
--- a/Plugin/WadoRsRetrieveRendered.cpp Fri Jan 17 12:24:54 2025 +0100 +++ b/Plugin/WadoRsRetrieveRendered.cpp Thu Apr 17 09:59:46 2025 +0200 @@ -821,10 +821,29 @@ static void AnswerFrameRendered(OrthancPluginRestOutput* output, - std::string instanceId, + const std::string& instanceId, + const std::string& transferSyntax, int frame, const OrthancPluginHttpRequest* request) { + // If the instance is a video, we shall provide the video file itself (MP4, ...) + Orthanc::DicomTransferSyntax currentSyntax; + if (Orthanc::LookupTransferSyntax(currentSyntax, transferSyntax)) + { + if (currentSyntax >= Orthanc::DicomTransferSyntax_MPEG2MainProfileAtMainLevel && currentSyntax <= Orthanc::DicomTransferSyntax_HEVCMain10ProfileLevel5_1) + { + OrthancPlugins::RestApiClient apiClient; + apiClient.SetPath(std::string("/instances/") + instanceId + "/frames/0/raw"); + if (apiClient.Execute()) + { + apiClient.Forward(OrthancPlugins::GetGlobalContext(), output); + return; + } + } + } + + // for other media types, try to generate a single image preview. + static const char* const PHOTOMETRIC_INTERPRETATION = "0028,0004"; static const char* const PER_FRAME_FUNCTIONAL_GROUPS_SEQUENCE = "5200,9230"; static const char* const PIXEL_VALUE_TRANSFORMATION_SEQUENCE = "0028,9145"; @@ -976,9 +995,11 @@ else { std::string instanceId, studyInstanceUid, seriesInstanceUid, sopInstanceUid; - if (LocateInstance(output, instanceId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, request)) + std::string transferSyntax; + + if (LocateInstance(output, instanceId, studyInstanceUid, seriesInstanceUid, sopInstanceUid, transferSyntax, request)) { - AnswerFrameRendered(output, instanceId, frame, request); + AnswerFrameRendered(output, instanceId, transferSyntax, frame, request); } else { @@ -1012,8 +1033,6 @@ const char* url, const OrthancPluginHttpRequest* request) { - static const char* const INSTANCES = "Instances"; - assert(request->groupsCount == 2); if (request->method != OrthancPluginHttpMethod_Get) @@ -1022,35 +1041,11 @@ } else { - std::string orthancId, studyInstanceUid, seriesInstanceUid; - if (LocateSeries(output, orthancId, studyInstanceUid, seriesInstanceUid, request)) + std::string instanceOrthancId, studyInstanceUid, seriesInstanceUid, transferSyntax; + if (LocateOneInstance(output, instanceOrthancId, studyInstanceUid, seriesInstanceUid, transferSyntax, request)) { - Json::Value series; - if (OrthancPlugins::RestApiGet(series, "/series/" + orthancId, false) && - series.type() == Json::objectValue && - series.isMember(INSTANCES) && - series[INSTANCES].type() == Json::arrayValue && - series[INSTANCES].size() > 0) - { - std::set<std::string> ids; - for (Json::Value::ArrayIndex i = 0; i < series[INSTANCES].size(); i++) - { - if (series[INSTANCES][i].type() != Json::stringValue) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); - } - else - { - ids.insert(series[INSTANCES][i].asString()); - } - } - - // Retrieve the first instance in alphanumeric order, in order - // to always return the same instance - std::string instanceId = *ids.begin(); - AnswerFrameRendered(output, instanceId, 1 /* first frame */, request); - return; // Success - } + AnswerFrameRendered(output, instanceOrthancId, transferSyntax, 1 /* first frame */, request); + return; // Success } throw Orthanc::OrthancException(Orthanc::ErrorCode_InexistentItem, "Inexistent series"); @@ -1062,8 +1057,6 @@ const char* url, const OrthancPluginHttpRequest* request) { - static const char* const ID = "ID"; - assert(request->groupsCount == 1); if (request->method != OrthancPluginHttpMethod_Get) @@ -1072,35 +1065,11 @@ } else { - std::string orthancId, studyInstanceUid; - if (LocateStudy(output, orthancId, studyInstanceUid, request)) + std::string instanceOrthancId, studyInstanceUid, seriesInstanceUid, transferSyntax; + if (LocateOneInstance(output, instanceOrthancId, studyInstanceUid, seriesInstanceUid, transferSyntax, request)) { - Json::Value instances; - if (OrthancPlugins::RestApiGet(instances, "/studies/" + orthancId + "/instances", false) && - instances.type() == Json::arrayValue && - instances.size() > 0) - { - std::set<std::string> ids; - for (Json::Value::ArrayIndex i = 0; i < instances.size(); i++) - { - if (instances[i].type() != Json::objectValue || - !instances[i].isMember(ID) || - instances[i][ID].type() != Json::stringValue) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); - } - else - { - ids.insert(instances[i][ID].asString()); - } - } - - // Retrieve the first instance in alphanumeric order, in order - // to always return the same instance - std::string instanceId = *ids.begin(); - AnswerFrameRendered(output, instanceId, 1 /* first frame */, request); - return; // Success - } + AnswerFrameRendered(output, instanceOrthancId, transferSyntax, 1 /* first frame */, request); + return; // Success } throw Orthanc::OrthancException(Orthanc::ErrorCode_InexistentItem, "Inexistent study");
--- a/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp Fri Jan 17 12:24:54 2025 +0100 +++ b/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp Thu Apr 17 09:59:46 2025 +0200 @@ -26,6 +26,7 @@ #include <boost/algorithm/string/predicate.hpp> #include <boost/move/unique_ptr.hpp> #include <boost/thread.hpp> +#include <boost/algorithm/string/join.hpp> #include <json/reader.h> @@ -4077,6 +4078,26 @@ } } + void SerializeGetArguments(std::string& output, const OrthancPluginHttpRequest* request) + { + output.clear(); + std::vector<std::string> arguments; + for (uint32_t i = 0; i < request->getCount; ++i) + { + if (request->getValues[i] && strlen(request->getValues[i]) > 0) + { + arguments.push_back(std::string(request->getKeys[i]) + "=" + std::string(request->getValues[i])); + } + else + { + arguments.push_back(std::string(request->getKeys[i])); + } + } + + output = boost::algorithm::join(arguments, "&"); + } + + #if !ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 4) static void SetPluginProperty(const std::string& pluginIdentifier, _OrthancPluginProperty property, @@ -4130,6 +4151,24 @@ httpStatus_(0) { } + + RestApiClient::RestApiClient(const char* url, + const OrthancPluginHttpRequest* request) : + method_(request->method), + path_(url), + afterPlugins_(false), + httpStatus_(0) + { + OrthancPlugins::GetHttpHeaders(requestHeaders_, request); + + std::string getArguments; + OrthancPlugins::SerializeGetArguments(getArguments, request); + + if (!getArguments.empty()) + { + path_ += "?" + getArguments; + } + } #endif @@ -4195,6 +4234,32 @@ } } } + + void RestApiClient::Forward(OrthancPluginContext* context, OrthancPluginRestOutput* output) + { + if (Execute() && httpStatus_ == 200) + { + const char* mimeType = NULL; + for (HttpHeaders::const_iterator h = answerHeaders_.begin(); h != answerHeaders_.end(); ++h) + { + if (h->first == "content-type") + { + mimeType = h->second.c_str(); + } + } + + AnswerString(answerBody_, mimeType, output); + } + else + { + AnswerHttpError(httpStatus_, output); + } + } + + bool RestApiClient::GetAnswerJson(Json::Value& output) const + { + return ReadJson(output, answerBody_); + } #endif
--- a/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h Fri Jan 17 12:24:54 2025 +0100 +++ b/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h Thu Apr 17 09:59:46 2025 +0200 @@ -1399,6 +1399,9 @@ // helper method to convert Http headers from the plugin SDK to a std::map void GetHttpHeaders(HttpHeaders& result, const OrthancPluginHttpRequest* request); +// helper method to re-serialize the get arguments from the SDK into a string +void SerializeGetArguments(std::string& output, const OrthancPluginHttpRequest* request); + #if HAS_ORTHANC_PLUGIN_WEBDAV == 1 class IWebDavCollection : public boost::noncopyable { @@ -1528,6 +1531,10 @@ public: RestApiClient(); + + // used to forward a call from the plugin to the core + RestApiClient(const char* url, + const OrthancPluginHttpRequest* request); void SetMethod(OrthancPluginHttpMethod method) { @@ -1584,12 +1591,17 @@ bool Execute(); + // Execute and forward the response as is + void Forward(OrthancPluginContext* context, OrthancPluginRestOutput* output); + uint16_t GetHttpStatus() const; bool LookupAnswerHeader(std::string& value, const std::string& key) const; const std::string& GetAnswerBody() const; + + bool GetAnswerJson(Json::Value& output) const; }; #endif }
--- a/TODO Fri Jan 17 12:24:54 2025 +0100 +++ b/TODO Thu Apr 17 09:59:46 2025 +0200 @@ -31,6 +31,8 @@ * Add support for application/zip in /dicom-web/studies/ (aka sup 211: https://www.dicomstandard.org/docs/librariesprovider2/dicomdocuments/news/ftsup/docs/sups/sup211.pdf?sfvrsn=9fe9edae_2) +* Add support for thumbnails (aka sup 203: https://www.dicomstandard.org/docs/librariesprovider2/dicomdocuments/news/progress/docs/sups/sup203.pdf) + * Support private tags in search fields: https://discourse.orthanc-server.org/t/dicomweb-plugin-exception-of-unknown-dicom-tag-for-private-data-element-tags-while-using-query-parameters/3998