changeset 6078:c314fccfe8b7

added argument "Encapsulate" to "/tools/create-dicom"
author Sebastien Jodogne <s.jodogne@gmail.com>
date Fri, 04 Apr 2025 15:30:00 +0200 (6 weeks ago)
parents 836d8b432ae3
children 4a03333b69f6
files NEWS OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h OrthancServer/Resources/Samples/Python/EncapsulateJPEG.py OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp
diffstat 5 files changed, 185 insertions(+), 17 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Fri Apr 04 13:00:58 2025 +0200
+++ b/NEWS	Fri Apr 04 15:30:00 2025 +0200
@@ -5,9 +5,11 @@
 --------
 
 * API version upgraded to 28
-* GET /studies/../archive and sibbling routes now all accept a 'filename' GET argument.
-* POST /studies/../archive and sibbling routes now all accept a 'Filename' query argument.
-* GET /instances/../file and sibbling ../attachments/../data routes now all accept a 'filename' GET argument.
+* POST /tools/create-dicom accepts new argument "Encapsulate" to encapsulate a raw JPEG
+  image into a DICOM envelope without transcoding, using 1.2.840.10008.1.2.4.50 transfer syntax.
+* GET /studies/../archive and sibbling routes now all accept a "filename" GET argument.
+* POST /studies/../archive and sibbling routes now all accept a "Filename" query argument.
+* GET /instances/../file and sibbling ../attachments/../data routes now all accept a "filename" GET argument.
 * All routes accepting a "transcode" url argument or a "Transcode" field in the payload now also
   accepts a "lossy-quality" url argument or a "LossyQuality" field to define the compression quality factor.
   If not specified, the "DicomLossyTranscodingQuality" configuration is taken into account.
@@ -29,16 +31,16 @@
 * Enabled support of the 1.2.840.10008.1.2.1.99 transfer syntax
   (Deflated Explicit VR Little Endian) in static builds + fix length of saved files.
   https://discourse.orthanc-server.org/t/transcoding-to-deflated-transfer-syntax-fails/5489
-* When anonymizing a resource while forcing some value with the 'Replace' fields, the tag
+* When anonymizing a resource while forcing some value with the "Replace" fields, the tag
   0012,0063 was cleared out because the DICOM Anonymization profile was not strictly followed.
   From now on:
-  - 0012,0063 will contain "Orthanc {version} - {Anonymization profile}" if no 'Replace' is used.
-  - 0012,0063 will contain "Orthanc {version}" if 'Replace' is used.
+  - 0012,0063 will contain "Orthanc {version} - {Anonymization profile}" if no "Replace" is used.
+  - 0012,0063 will contain "Orthanc {version}" if "Replace" is used.
   (https://orthanc.uclouvain.be/bugs/show_bug.cgi?id=240)
 * Housekeeper plugin:
   - When encountering an error, the housekeeper now skips the resource and continues processing.
 * Orthanc Explorer:
-  - Allow '-' and '_' in labels.
+  - Allow "-" and "_" in labels.
 * Upgraded dependencies for static builds:
   - lua 5.4.7
 
--- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Fri Apr 04 13:00:58 2025 +0200
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Fri Apr 04 15:30:00 2025 +0200
@@ -1312,13 +1312,14 @@
   }
 
 
-  void ParsedDicomFile::EmbedImage(const ImageAccessor& accessor)
+  void ParsedDicomFile::ConfigureTagsForUncompressedImage(unsigned int& bytesPerPixel /* out */,
+                                                          const ImageAccessor& accessor)
   {
     if (accessor.GetFormat() != PixelFormat_Grayscale8 &&
         accessor.GetFormat() != PixelFormat_Grayscale16 &&
         accessor.GetFormat() != PixelFormat_SignedGrayscale16 &&
         accessor.GetFormat() != PixelFormat_RGB24 &&
-        accessor.GetFormat() != PixelFormat_RGBA32 && 
+        accessor.GetFormat() != PixelFormat_RGBA32 &&
         accessor.GetFormat() != PixelFormat_RGBA64)
     {
       throw OrthancException(ErrorCode_NotImplemented);
@@ -1326,7 +1327,7 @@
 
     InvalidateCache();
 
-    if (accessor.GetFormat() == PixelFormat_RGBA32 || 
+    if (accessor.GetFormat() == PixelFormat_RGBA32 ||
         accessor.GetFormat() == PixelFormat_RGBA64)
     {
       LOG(WARNING) << "Getting rid of the alpha channel when embedding a RGBA image inside DICOM";
@@ -1351,7 +1352,7 @@
       ReplacePlainString(DICOM_TAG_PIXEL_REPRESENTATION, "0");  // Unsigned pixels
     }
 
-    unsigned int bytesPerPixel = 0;
+    bytesPerPixel = 0;
 
     switch (accessor.GetFormat())
     {
@@ -1379,7 +1380,7 @@
         ReplacePlainString(DICOM_TAG_PLANAR_CONFIGURATION, "0");  // Color channels are interleaved
 
         break;
-      
+
       case PixelFormat_RGBA64:
         ReplacePlainString(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "RGB");
         ReplacePlainString(DICOM_TAG_SAMPLES_PER_PIXEL, "3");
@@ -1410,6 +1411,12 @@
     }
 
     assert(bytesPerPixel != 0);
+  }
+
+  void ParsedDicomFile::EmbedImage(const ImageAccessor& accessor)
+  {
+    unsigned int bytesPerPixel = 0;
+    ConfigureTagsForUncompressedImage(bytesPerPixel, accessor);
 
     DcmTag key(DICOM_TAG_PIXEL_DATA.GetGroup(), 
                DICOM_TAG_PIXEL_DATA.GetElement());
@@ -2232,6 +2239,63 @@
   }
 
 
+  void ParsedDicomFile::EncapsulatePixelData(const std::string& dataUriScheme)
+  {
+    std::string mime, content;
+    if (!Toolbox::DecodeDataUriScheme(mime, content, dataUriScheme))
+    {
+      throw OrthancException(ErrorCode_BadFileFormat);
+    }
+
+    Remove(DICOM_TAG_PIXEL_DATA);
+
+    if (mime == MIME_JPEG)
+    {
+#if ORTHANC_ENABLE_JPEG == 1
+      JpegReader reader;
+      reader.ReadFromMemory(content);
+      unsigned int bytesPerPixel = 0;
+
+      ConfigureTagsForUncompressedImage(bytesPerPixel, reader);
+
+      if (reader.GetFormat() == PixelFormat_RGB24)
+      {
+        ReplacePlainString(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "YBR_FULL_422");
+      }
+
+      Uint8* raw = const_cast<Uint8*>(reinterpret_cast<const Uint8*>(content.c_str()));
+
+      DcmOffsetList offsetList;
+
+      std::unique_ptr<DcmPixelSequence> pixelSequence(new DcmPixelSequence(DCM_PixelData));
+
+      DcmPixelItem* offsetTable = new DcmPixelItem(DCM_PixelItemTag);
+      if (!pixelSequence->insert(offsetTable).good() ||
+          !pixelSequence->storeCompressedFrame(offsetList, raw, content.size(), 0 /* unlimited fragment size */).good() ||
+          !offsetTable->createOffsetTable(offsetList).good())
+      {
+        throw OrthancException(ErrorCode_InternalError);
+      }
+
+      std::unique_ptr<DcmPixelData> pixelData(new DcmPixelData(DCM_PixelData));
+      pixelData->putOriginalRepresentation(EXS_JPEGProcess1, NULL, pixelSequence.release());
+
+      if (!GetDcmtkObject().getDataset()->insert(pixelData.release(), true, false).good() ||
+          !GetDcmtkObject().getDataset()->chooseRepresentation(EXS_JPEGProcess1, NULL).good())
+      {
+        throw OrthancException(ErrorCode_InternalError);
+      }
+#else
+      throw OrthancException(ErrorCode_InternalError, "Orthanc was compiled without support for JPEG");
+#endif
+    }
+    else
+    {
+      throw OrthancException(ErrorCode_NotImplemented, "Cannot encapsulate pixel data from MIME type: " + mime);
+    }
+  }
+
+
 #if ORTHANC_BUILDING_FRAMEWORK_LIBRARY == 1
   // Alias for binary compatibility with Orthanc Framework 1.7.2 => don't use it anymore
   void ParsedDicomFile::DatasetToJson(Json::Value& target,
--- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h	Fri Apr 04 13:00:58 2025 +0200
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h	Fri Apr 04 15:30:00 2025 +0200
@@ -105,6 +105,9 @@
     // the top of DCMTK API
     DcmFileFormat& GetDcmtkObjectConst() const;
 
+    void ConfigureTagsForUncompressedImage(unsigned int& bytesPerPixel /* out */,
+                                           const ImageAccessor& accessor);
+
     explicit ParsedDicomFile(DcmFileFormat* dicom);  // This takes ownership (no clone)
 
 #if ORTHANC_BUILDING_FRAMEWORK_LIBRARY == 1
@@ -319,5 +322,7 @@
     void RemoveFromPixelData();
 
     ValueRepresentation GuessPixelDataValueRepresentation() const;
+
+    void EncapsulatePixelData(const std::string& dataUriScheme);
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Resources/Samples/Python/EncapsulateJPEG.py	Fri Apr 04 15:30:00 2025 +0200
@@ -0,0 +1,72 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+
+# 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 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
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+#
+# This sample Python script illustrates how to encapsulate a JPEG
+# image into a DICOM enveloppe, without any transcoding.
+#
+
+import base64
+import json
+import requests
+
+
+##################
+##  Parameters  ##
+##################
+
+JPEG = '/tmp/sample.jpg'
+URL = 'http://localhost:8042/'
+USERNAME = 'orthanc'
+PASSWORD = 'orthanc'
+
+TAGS = {
+    'PatientID' : 'Test',
+    'PatientName' : 'Hello^World',
+    'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.7',  # Secondary capture
+    }
+
+
+
+########################################
+##  Application of the DICOM-ization  ##
+########################################
+
+with open(JPEG, 'rb') as f:
+    jpeg = f.read()
+
+b = base64.b64encode(jpeg)
+content = 'data:image/jpeg;base64,%s' % b.decode()
+
+command = {
+    'Content' : content,
+    'Tags' : TAGS,
+    'Encapsulate' : True,
+}
+
+r = requests.post('%s/tools/create-dicom' % URL, json.dumps(command),
+                  auth = requests.auth.HTTPBasicAuth(USERNAME, PASSWORD))
+r.raise_for_status()
+
+print('URL of the newly created instance: %s/instances/%s/file' % (URL, r.json() ['ID']))
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Fri Apr 04 13:00:58 2025 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Fri Apr 04 15:30:00 2025 +0200
@@ -59,6 +59,7 @@
 static const char* const TAGS = "Tags";
 static const char* const TRANSCODE = "Transcode";
 static const char* const LOSSY_QUALITY = "LossyQuality";
+static const char* const ENCAPSULATE = "Encapsulate";  // New in Orthanc 1.12.7
 
 
 
@@ -1008,20 +1009,40 @@
     // Inject the content (either an image, a PDF file, or a STL/OBJ/MTL file)
     if (request.isMember(CONTENT))
     {
+      bool encapsulate = false;
+      if (request.isMember(ENCAPSULATE))
+      {
+        encapsulate = request[ENCAPSULATE].asBool();
+      }
+
       const Json::Value& content = request[CONTENT];
 
       if (content.type() == Json::stringValue)
       {
-        dicom.EmbedContent(request[CONTENT].asString());
-
+        if (encapsulate)
+        {
+          // New in Orthanc 1.12.7
+          dicom.EncapsulatePixelData(request[CONTENT].asString());
+        }
+        else
+        {
+          dicom.EmbedContent(request[CONTENT].asString());
+        }
       }
       else if (content.type() == Json::arrayValue)
       {
         if (content.size() > 0)
         {
-          // Let's create a series instead of a single instance
-          CreateSeries(call, dicom, content, decodeBinaryTags, privateCreator, force);
-          return;
+          if (encapsulate)
+          {
+            throw OrthancException(ErrorCode_NotImplemented);
+          }
+          else
+          {
+            // Let's create a series instead of a single instance
+            CreateSeries(call, dicom, content, decodeBinaryTags, privateCreator, force);
+            return;
+          }
         }
       }
       else
@@ -1066,6 +1087,10 @@
                          "Avoid the consistency checks for the DICOM tags that enforce the DICOM model of the real-world. "
                          "You can notably use this flag if you need to manually set the tags `StudyInstanceUID`, "
                          "`SeriesInstanceUID`, or `SOPInstanceUID`. Be careful with this feature.", false)
+        .SetRequestField(ENCAPSULATE, RestApiCallDocumentation::Type_Boolean,
+                         "If set to `true`, encapsulate the binary data of `ContentData` as such, using a compressed transfer syntax. "
+                         "Only applicable if `ContentData` contains a grayscale or color JPEG image in 8bpp, "
+                         "in which case the transfer syntax is set to \"1.2.840.10008.1.2.4.50\". (new in Orthanc 1.12.7)", false)
         .SetAnswerField("ID", RestApiCallDocumentation::Type_String, "Orthanc identifier of the newly created instance")
         .SetAnswerField("Path", RestApiCallDocumentation::Type_String, "Path to access the instance in the REST API");
       return;