Mercurial > hg > orthanc
changeset 4834:bec432ee1094
download of numpy arrays from the REST API
author | Sebastien Jodogne <s.jodogne@gmail.com> |
---|---|
date | Fri, 26 Nov 2021 19:03:32 +0100 |
parents | 970092a67897 |
children | f3f93695d6df |
files | NEWS OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake OrthancFramework/Sources/Images/NumpyWriter.cpp OrthancFramework/Sources/Images/NumpyWriter.h OrthancFramework/Sources/RestApi/RestApiGetCall.cpp OrthancFramework/Sources/RestApi/RestApiGetCall.h OrthancFramework/Sources/SerializationToolbox.cpp OrthancFramework/Sources/SerializationToolbox.h OrthancFramework/Sources/WebServiceParameters.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp |
diffstat | 10 files changed, 632 insertions(+), 16 deletions(-) [+] |
line wrap: on
line diff
--- a/NEWS Thu Nov 25 19:05:41 2021 +0100 +++ b/NEWS Fri Nov 26 19:03:32 2021 +0100 @@ -30,7 +30,10 @@ unsupported.png only if the ?returnUnsupportedImage option is specified; otherwise, it raises a 415 error code. * Archive jobs response now contains a header Content-Disposition:filename='archive.zip' - +* "/instances/{...}/frames/{...}/numpy": Download the frame as a Python numpy array +* "/instances/{...}/numpy": Download the instance as a Python numpy array +* "/series/{...}/numpy": Download the series as a Python numpy array + Lua ---
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake Fri Nov 26 19:03:32 2021 +0100 @@ -183,6 +183,7 @@ ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/ImageAccessor.cpp ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/ImageBuffer.cpp ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/ImageProcessing.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/NumpyWriter.cpp ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/PamReader.cpp ${CMAKE_CURRENT_LIST_DIR}/../../Sources/Images/PamWriter.cpp )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OrthancFramework/Sources/Images/NumpyWriter.cpp Fri Nov 26 19:03:32 2021 +0100 @@ -0,0 +1,236 @@ +/** + * Orthanc - A Lightweight, RESTful DICOM Store + * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics + * Department, University Hospital of Liege, Belgium + * Copyright (C) 2017-2021 Osimis S.A., Belgium + * Copyright (C) 2021-2021 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 "NumpyWriter.h" + +#if ORTHANC_ENABLE_ZLIB == 1 +# include "../Compression/ZipWriter.h" +#endif + +#if ORTHANC_SANDBOXED == 0 +# include "../SystemToolbox.h" +#endif + +#include "../OrthancException.h" +#include "../Toolbox.h" + +#include <boost/lexical_cast.hpp> + +namespace Orthanc +{ + void NumpyWriter::WriteHeader(ChunkedBuffer& target, + unsigned int depth, + unsigned int width, + unsigned int height, + PixelFormat format) + { + // https://numpy.org/devdocs/reference/generated/numpy.lib.format.html + static const unsigned char VERSION[] = { + 0x93, 'N', 'U', 'M', 'P', 'Y', + 0x01 /* major version: 1 */, + 0x00 /* minor version: 0 */ + }; + + std::string datatype; + + switch (Toolbox::DetectEndianness()) + { + case Endianness_Little: + datatype = "<"; + break; + + case Endianness_Big: + datatype = ">"; + break; + + default: + throw OrthancException(ErrorCode_InternalError); + } + + unsigned int channels; + + switch (format) + { + case PixelFormat_Grayscale8: + datatype += "u1"; + channels = 1; + break; + + case PixelFormat_Grayscale16: + datatype += "u2"; + channels = 1; + break; + + case PixelFormat_SignedGrayscale16: + datatype += "s2"; + channels = 1; + break; + + case PixelFormat_RGB24: + datatype += "u1"; + channels = 3; + break; + + case PixelFormat_Float32: + datatype += "f4"; + channels = 1; + break; + + default: + throw OrthancException(ErrorCode_NotImplemented); + } + + std::string depthString; + if (depth != 0) + { + depthString = boost::lexical_cast<std::string>(depth) + ", "; + } + + const std::string info = ("{'descr': '" + datatype + "', 'fortran_order': False, " + + "'shape': (" + depthString + boost::lexical_cast<std::string>(height) + + "," + boost::lexical_cast<std::string>(width) + + "," + boost::lexical_cast<std::string>(channels) + "), }"); + + const uint16_t minimumLength = sizeof(VERSION) + sizeof(uint16_t) + info.size() + 1 /* trailing '\n' */; + + // The length of the header must be evenly divisible by 64. This + // loop could be optimized by a "ceil()" operation, but we keep + // the code as simple as possible + uint16_t length = 64; + while (length < minimumLength) + { + length += 64; + } + + uint16_t countZeros = length - minimumLength; + uint16_t headerLength = info.size() + countZeros + 1 /* trailing '\n' */; + uint8_t highByte = headerLength / 256; + uint8_t lowByte = headerLength % 256; + + target.AddChunk(VERSION, sizeof(VERSION)); + target.AddChunk(&lowByte, 1); + target.AddChunk(&highByte, 1); + target.AddChunk(info); + target.AddChunk(std::string(countZeros, ' ')); + target.AddChunk("\n"); + } + + + void NumpyWriter::WritePixels(ChunkedBuffer& target, + const ImageAccessor& image) + { + size_t rowSize = image.GetBytesPerPixel() * image.GetWidth(); + + for (unsigned int y = 0; y < image.GetHeight(); y++) + { + target.AddChunk(image.GetConstRow(y), rowSize); + } + } + + + void NumpyWriter::Finalize(std::string& target, + ChunkedBuffer& source, + bool compress) + { + if (compress) + { +#if ORTHANC_ENABLE_ZLIB == 1 + // This is the default name of the first array if arrays are + // specified as positional arguments in "numpy.savez()" + // https://numpy.org/doc/stable/reference/generated/numpy.savez.html + const char* ARRAY_NAME = "arr_0"; + + std::string uncompressed; + source.Flatten(uncompressed); + + const bool isZip64 = (uncompressed.size() >= 1lu * 1024lu * 1024lu * 1024lu); + + ZipWriter writer; + writer.SetMemoryOutput(target, isZip64); + writer.Open(); + writer.OpenFile(ARRAY_NAME); + writer.Write(uncompressed); + writer.Close(); +#else + throw OrthancException(ErrorCode_InternalError, "Orthanc was compiled without support for zlib"); +#endif + } + else + { + source.Flatten(target); + } + } + + +#if ORTHANC_SANDBOXED == 0 + void NumpyWriter::WriteToFileInternal(const std::string& filename, + unsigned int width, + unsigned int height, + unsigned int pitch, + PixelFormat format, + const void* buffer) + { + std::string content; + WriteToMemoryInternal(content, width, height, pitch, format, buffer); + + SystemToolbox::WriteFile(content, filename); + } +#endif + + + void NumpyWriter::WriteToMemoryInternal(std::string& content, + unsigned int width, + unsigned int height, + unsigned int pitch, + PixelFormat format, + const void* buffer) + { + ChunkedBuffer chunks; + WriteHeader(chunks, 0 /* no depth */, width, height, format); + + ImageAccessor image; + image.AssignReadOnly(format, width, height, pitch, buffer); + WritePixels(chunks, image); + + Finalize(content, chunks, compressed_); + } + + + NumpyWriter::NumpyWriter() + { + compressed_ = false; + } + + + void NumpyWriter::SetCompressed(bool compressed) + { +#if ORTHANC_ENABLE_ZLIB == 1 + compressed_ = compressed; +#else + if (compressed) + { + throw OrthancException(ErrorCode_InternalError, "Orthanc was compiled without support for zlib"); + } +#endif + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OrthancFramework/Sources/Images/NumpyWriter.h Fri Nov 26 19:03:32 2021 +0100 @@ -0,0 +1,81 @@ +/** + * Orthanc - A Lightweight, RESTful DICOM Store + * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics + * Department, University Hospital of Liege, Belgium + * Copyright (C) 2017-2021 Osimis S.A., Belgium + * Copyright (C) 2021-2021 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 + +#if !defined(ORTHANC_ENABLE_ZLIB) +# error The macro ORTHANC_ENABLE_ZLIB must be defined +#endif + +#include "IImageWriter.h" +#include "../ChunkedBuffer.h" +#include "../Compatibility.h" // For ORTHANC_OVERRIDE + +namespace Orthanc +{ + class ORTHANC_PUBLIC NumpyWriter : public IImageWriter + { + protected: +#if ORTHANC_SANDBOXED == 0 + virtual void WriteToFileInternal(const std::string& filename, + unsigned int width, + unsigned int height, + unsigned int pitch, + PixelFormat format, + const void* buffer) ORTHANC_OVERRIDE; +#endif + + virtual void WriteToMemoryInternal(std::string& content, + unsigned int width, + unsigned int height, + unsigned int pitch, + PixelFormat format, + const void* buffer) ORTHANC_OVERRIDE; + + private: + bool compressed_; + + public: + NumpyWriter(); + + void SetCompressed(bool compressed); + + bool IsCompressed() const + { + return compressed_; + } + + static void WriteHeader(ChunkedBuffer& target, + unsigned int depth, // Must be "0" for 2D images + unsigned int width, + unsigned int height, + PixelFormat format); + + static void WritePixels(ChunkedBuffer& target, + const ImageAccessor& image); + + static void Finalize(std::string& target, + ChunkedBuffer& source, + bool compress); + }; +}
--- a/OrthancFramework/Sources/RestApi/RestApiGetCall.cpp Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Sources/RestApi/RestApiGetCall.cpp Fri Nov 26 19:03:32 2021 +0100 @@ -24,6 +24,9 @@ #include "../PrecompiledHeaders.h" #include "RestApiGetCall.h" +#include "../OrthancException.h" +#include "../SerializationToolbox.h" + namespace Orthanc { bool RestApiGetCall::ParseJsonRequest(Json::Value& result) const @@ -38,4 +41,31 @@ return true; } + + + bool RestApiGetCall::GetBooleanArgument(const std::string& name, + bool defaultValue) const + { + HttpToolbox::Arguments::const_iterator found = getArguments_.find(name); + + bool value; + + if (found == getArguments_.end()) + { + return defaultValue; + } + else if (found->second.empty()) + { + return true; + } + else if (SerializationToolbox::ParseBoolean(value, found->second)) + { + return value; + } + else + { + throw OrthancException(ErrorCode_ParameterOutOfRange, "Expected a Boolean for GET argument \"" + + name + "\", found: " + found->second); + } + } }
--- a/OrthancFramework/Sources/RestApi/RestApiGetCall.h Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Sources/RestApi/RestApiGetCall.h Fri Nov 26 19:03:32 2021 +0100 @@ -61,6 +61,9 @@ { return getArguments_.find(name) != getArguments_.end(); } + + bool GetBooleanArgument(const std::string& name, + bool defaultValue) const; virtual bool ParseJsonRequest(Json::Value& result) const ORTHANC_OVERRIDE; };
--- a/OrthancFramework/Sources/SerializationToolbox.cpp Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Sources/SerializationToolbox.cpp Fri Nov 26 19:03:32 2021 +0100 @@ -648,4 +648,26 @@ return false; } } + + + bool SerializationToolbox::ParseBoolean(bool& result, + const std::string& value) + { + if (value == "0" || + value == "false") + { + result = false; + return true; + } + else if (value == "1" || + value == "true") + { + result = true; + return true; + } + else + { + return false; + } + } }
--- a/OrthancFramework/Sources/SerializationToolbox.h Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Sources/SerializationToolbox.h Fri Nov 26 19:03:32 2021 +0100 @@ -138,5 +138,8 @@ static bool ParseFirstDouble(double& result, const std::string& value); + + static bool ParseBoolean(bool& result, + const std::string& value); }; }
--- a/OrthancFramework/Sources/WebServiceParameters.cpp Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancFramework/Sources/WebServiceParameters.cpp Fri Nov 26 19:03:32 2021 +0100 @@ -505,21 +505,19 @@ { return defaultValue; } - else if (found->second == "0" || - found->second == "false") - { - return false; - } - else if (found->second == "1" || - found->second == "true") - { - return true; - } else { - throw OrthancException(ErrorCode_BadFileFormat, "Bad value for a Boolean user property in the parameters " - "of a Web service: Property \"" + key + "\" equals: " + found->second); - } + bool value; + if (SerializationToolbox::ParseBoolean(value, found->second)) + { + return value; + } + else + { + throw OrthancException(ErrorCode_BadFileFormat, "Bad value for a Boolean user property in the parameters " + "of a Web service: Property \"" + key + "\" equals: " + found->second); + } + } }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp Thu Nov 25 19:05:41 2021 +0100 +++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp Fri Nov 26 19:03:32 2021 +0100 @@ -43,6 +43,7 @@ #include "../../../OrthancFramework/Sources/HttpServer/HttpContentNegociation.h" #include "../../../OrthancFramework/Sources/Images/Image.h" #include "../../../OrthancFramework/Sources/Images/ImageProcessing.h" +#include "../../../OrthancFramework/Sources/Images/NumpyWriter.h" #include "../../../OrthancFramework/Sources/Logging.h" #include "../../../OrthancFramework/Sources/MultiThreading/Semaphore.h" #include "../../../OrthancFramework/Sources/SerializationToolbox.h" @@ -1081,9 +1082,9 @@ windowWidth = 1; } - if (std::abs(rescaleSlope) <= 0.1) + if (std::abs(rescaleSlope) <= 0.0001) { - rescaleSlope = 0.1; + rescaleSlope = 0.0001; } const double scaling = 255.0 * rescaleSlope / windowWidth; @@ -1142,6 +1143,241 @@ } + static void DocumentSharedNumpy(RestApiGetCall& call) + { + call.GetDocumentation() + .SetUriArgument("id", "Orthanc identifier of the DICOM resource of interest") + .SetHttpGetArgument("compress", RestApiCallDocumentation::Type_Boolean, "Compress the file as `.npz`", false) + .SetHttpGetArgument("rescale", RestApiCallDocumentation::Type_Boolean, + "On grayscale images, apply the rescaling and return floating-point values", false) + .AddAnswerType(MimeType_PlainText, "Numpy file: https://numpy.org/devdocs/reference/generated/numpy.lib.format.html"); + } + + + namespace + { + class NumpyVisitor : public boost::noncopyable + { + private: + bool rescale_; + unsigned int depth_; + unsigned int currentDepth_; + unsigned int height_; + unsigned int width_; + PixelFormat format_; + ChunkedBuffer buffer_; + + public: + NumpyVisitor(unsigned int depth /* can be zero if 2D frame */, + bool rescale) : + rescale_(rescale), + depth_(depth), + currentDepth_(0) + { + } + + void WriteFrame(ParsedDicomFile& dicom, + unsigned int frame) + { + std::unique_ptr<ImageAccessor> decoded(dicom.DecodeFrame(frame)); + + if (decoded.get() == NULL) + { + throw OrthancException(ErrorCode_NotImplemented, "Cannot decode DICOM instance"); + } + + if (currentDepth_ == 0) + { + width_ = decoded->GetWidth(); + height_ = decoded->GetHeight(); + format_ = decoded->GetFormat(); + } + else if (width_ != decoded->GetWidth() || + height_ != decoded->GetHeight()) + { + throw OrthancException(ErrorCode_IncompatibleImageSize, "The size of the frames varies across the instance(s)"); + } + else if (format_ != decoded->GetFormat()) + { + throw OrthancException(ErrorCode_IncompatibleImageFormat, "The pixel format of the frames varies across the instance(s)"); + } + + if (rescale_ && + decoded->GetFormat() != PixelFormat_RGB24) + { + if (currentDepth_ == 0) + { + NumpyWriter::WriteHeader(buffer_, depth_, width_, height_, PixelFormat_Float32); + } + + double rescaleIntercept, rescaleSlope; + dicom.GetRescale(rescaleIntercept, rescaleSlope, frame); + + Image converted(PixelFormat_Float32, decoded->GetWidth(), decoded->GetHeight(), false); + ImageProcessing::Convert(converted, *decoded); + ImageProcessing::ShiftScale2(converted, static_cast<float>(rescaleIntercept), static_cast<float>(rescaleSlope), false); + + NumpyWriter::WritePixels(buffer_, converted); + } + else + { + if (currentDepth_ == 0) + { + NumpyWriter::WriteHeader(buffer_, depth_, width_, height_, format_); + } + + NumpyWriter::WritePixels(buffer_, *decoded); + } + + currentDepth_ ++; + } + + void Answer(RestApiOutput& output, + bool compress) + { + if ((depth_ == 0 && currentDepth_ != 1) || + (depth_ != 0 && currentDepth_ != depth_)) + { + throw OrthancException(ErrorCode_BadSequenceOfCalls); + } + else + { + std::string answer; + NumpyWriter::Finalize(answer, buffer_, compress); + output.AnswerBuffer(answer, MimeType_Binary); + } + } + }; + } + + + static void GetNumpyFrame(RestApiGetCall& call) + { + if (call.IsDocumentation()) + { + DocumentSharedNumpy(call); + call.GetDocumentation() + .SetTag("Instances") + .SetSummary("Decode frame for numpy") + .SetDescription("Decode one frame of interest from the given DICOM instance, for use with numpy in Python") + .SetUriArgument("frame", RestApiCallDocumentation::Type_Number, "Index of the frame (starts at `0`)"); + } + else + { + const std::string instanceId = call.GetUriComponent("id", ""); + const bool compress = call.GetBooleanArgument("compress", false); + const bool rescale = call.GetBooleanArgument("rescale", true); + + uint32_t frame; + if (!SerializationToolbox::ParseUnsignedInteger32(frame, call.GetUriComponent("frame", "0"))) + { + throw OrthancException(ErrorCode_ParameterOutOfRange, "Expected an unsigned integer for the \"frame\" argument"); + } + + NumpyVisitor visitor(0 /* no depth, 2D frame */, rescale); + + { + Semaphore::Locker throttling(throttlingSemaphore_); + ServerContext::DicomCacheLocker locker(OrthancRestApi::GetContext(call), instanceId); + + visitor.WriteFrame(locker.GetDicom(), frame); + } + + visitor.Answer(call.GetOutput(), compress); + } + } + + + static void GetNumpyInstance(RestApiGetCall& call) + { + if (call.IsDocumentation()) + { + DocumentSharedNumpy(call); + call.GetDocumentation() + .SetTag("Instances") + .SetSummary("Decode instance for numpy") + .SetDescription("Decode the given DICOM instance, for use with numpy in Python"); + } + else + { + const std::string instanceId = call.GetUriComponent("id", ""); + const bool compress = call.GetBooleanArgument("compress", false); + const bool rescale = call.GetBooleanArgument("rescale", true); + + { + Semaphore::Locker throttling(throttlingSemaphore_); + ServerContext::DicomCacheLocker locker(OrthancRestApi::GetContext(call), instanceId); + + const unsigned int depth = locker.GetDicom().GetFramesCount(); + if (depth == 0) + { + throw OrthancException(ErrorCode_BadFileFormat, "Empty DICOM instance"); + } + + NumpyVisitor visitor(depth, rescale); + + for (unsigned int frame = 0; frame < depth; frame++) + { + visitor.WriteFrame(locker.GetDicom(), frame); + } + + visitor.Answer(call.GetOutput(), compress); + } + } + } + + + static void GetNumpySeries(RestApiGetCall& call) + { + if (call.IsDocumentation()) + { + DocumentSharedNumpy(call); + call.GetDocumentation() + .SetTag("Series") + .SetSummary("Decode series for numpy") + .SetDescription("Decode the given DICOM series, for use with numpy in Python"); + } + else + { + const std::string seriesId = call.GetUriComponent("id", ""); + const bool compress = call.GetBooleanArgument("compress", false); + const bool rescale = call.GetBooleanArgument("rescale", true); + + Semaphore::Locker throttling(throttlingSemaphore_); + + ServerIndex& index = OrthancRestApi::GetIndex(call); + SliceOrdering ordering(index, seriesId); + + unsigned int depth = 0; + for (size_t i = 0; i < ordering.GetInstancesCount(); i++) + { + depth += ordering.GetFramesCount(i); + } + + ServerContext& context = OrthancRestApi::GetContext(call); + + NumpyVisitor visitor(depth, rescale); + + for (size_t i = 0; i < ordering.GetInstancesCount(); i++) + { + const std::string& instanceId = ordering.GetInstanceId(i); + unsigned int framesCount = ordering.GetFramesCount(i); + + { + ServerContext::DicomCacheLocker locker(context, instanceId); + + for (unsigned int frame = 0; frame < framesCount; frame++) + { + visitor.WriteFrame(locker.GetDicom(), frame); + } + } + } + + visitor.Answer(call.GetOutput(), compress); + } + } + + static void GetMatlabImage(RestApiGetCall& call) { if (call.IsDocumentation()) @@ -3396,6 +3632,7 @@ Register("/instances/{id}/frames/{frame}/matlab", GetMatlabImage); Register("/instances/{id}/frames/{frame}/raw", GetRawFrame<false>); Register("/instances/{id}/frames/{frame}/raw.gz", GetRawFrame<true>); + Register("/instances/{id}/frames/{frame}/numpy", GetNumpyFrame); // New in Orthanc 1.9.8 Register("/instances/{id}/pdf", ExtractPdf); Register("/instances/{id}/preview", GetImage<ImageExtractionMode_Preview>); Register("/instances/{id}/rendered", GetRenderedFrame); @@ -3404,6 +3641,7 @@ Register("/instances/{id}/image-int16", GetImage<ImageExtractionMode_Int16>); Register("/instances/{id}/matlab", GetMatlabImage); Register("/instances/{id}/header", GetInstanceHeader); + Register("/instances/{id}/numpy", GetNumpyInstance); // New in Orthanc 1.9.8 Register("/patients/{id}/protected", IsProtectedPatient); Register("/patients/{id}/protected", SetPatientProtection); @@ -3462,6 +3700,7 @@ Register("/instances/{id}/content/*", GetRawContent); Register("/series/{id}/ordered-slices", OrderSlices); + Register("/series/{id}/numpy", GetNumpySeries); // New in Orthanc 1.9.8 Register("/patients/{id}/reconstruct", ReconstructResource<ResourceType_Patient>); Register("/studies/{id}/reconstruct", ReconstructResource<ResourceType_Study>);