# HG changeset patch # User Sebastien Jodogne # Date 1689189794 -7200 # Node ID a43c2e6abce68adfcf493fdb076075b278fcf737 # Parent b5b9719ef1c0997249e3b6a6912c7b3dbb4637fc# Parent 04ea47a422d1ebcba3001c1cb2d927813f25d9ae integration iiif->mainline diff -r b5b9719ef1c0 -r a43c2e6abce6 Applications/Dicomizer.cpp --- a/Applications/Dicomizer.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Applications/Dicomizer.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -344,7 +344,7 @@ } // VL Whole Slide Microscopy Image IOD - OrthancWSI::DicomToolbox::SetStringTag(*dataset, DCM_SOPClassUID, "1.2.840.10008.5.1.4.1.1.77.1.6"); + OrthancWSI::DicomToolbox::SetStringTag(*dataset, DCM_SOPClassUID, OrthancWSI::VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE_IOD); // Slide Microscopy OrthancWSI::DicomToolbox::SetStringTag(*dataset, DCM_Modality, "SM"); diff -r b5b9719ef1c0 -r a43c2e6abce6 Framework/Enumerations.h --- a/Framework/Enumerations.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Framework/Enumerations.h Wed Jul 12 21:23:14 2023 +0200 @@ -29,6 +29,8 @@ namespace OrthancWSI { + static const char* const VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE_IOD = "1.2.840.10008.5.1.4.1.1.77.1.6"; + // WARNING - Don't change the enum values below, as this would break // serialization of "DicomPyramidInstance" enum ImageCompression diff -r b5b9719ef1c0 -r a43c2e6abce6 Framework/Inputs/DicomPyramidInstance.cpp --- a/Framework/Inputs/DicomPyramidInstance.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Framework/Inputs/DicomPyramidInstance.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -160,7 +160,7 @@ FullOrthancDataset dataset(orthanc, "/instances/" + instanceId + "/tags"); DicomDatasetReader reader(dataset); - if (reader.GetMandatoryStringValue(Orthanc::DicomPath(Orthanc::DICOM_TAG_SOP_CLASS_UID)) != "1.2.840.10008.5.1.4.1.1.77.1.6" || + if (reader.GetMandatoryStringValue(Orthanc::DicomPath(Orthanc::DICOM_TAG_SOP_CLASS_UID)) != VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE_IOD || reader.GetMandatoryStringValue(Orthanc::DicomPath(Orthanc::DICOM_TAG_MODALITY)) != "SM") { throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange); diff -r b5b9719ef1c0 -r a43c2e6abce6 NEWS --- a/NEWS Wed Jul 12 18:22:14 2023 +0200 +++ b/NEWS Wed Jul 12 21:23:14 2023 +0200 @@ -1,7 +1,10 @@ Pending changes in the mainline =============================== +* Support of IIIF * Automated extraction of the imaged volume if using OpenSlide +* URI "/wsi/tiles/{id}/{z}/{x}/{y}" supports the "Accept" HTTP header + to force JPEG, JPEG2k or PNG in the decoded tiles Version 1.1 (2021-12-11) diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/DicomDatasetReader.cpp --- a/Resources/Orthanc/Stone/DicomDatasetReader.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/DicomDatasetReader.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/DicomDatasetReader.h --- a/Resources/Orthanc/Stone/DicomDatasetReader.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/DicomDatasetReader.h Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/FullOrthancDataset.cpp --- a/Resources/Orthanc/Stone/FullOrthancDataset.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/FullOrthancDataset.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/FullOrthancDataset.h --- a/Resources/Orthanc/Stone/FullOrthancDataset.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/FullOrthancDataset.h Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/IDicomDataset.h --- a/Resources/Orthanc/Stone/IDicomDataset.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/IDicomDataset.h Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/IOrthancConnection.cpp --- a/Resources/Orthanc/Stone/IOrthancConnection.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/IOrthancConnection.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/IOrthancConnection.h --- a/Resources/Orthanc/Stone/IOrthancConnection.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/IOrthancConnection.h Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/OrthancHttpConnection.cpp --- a/Resources/Orthanc/Stone/OrthancHttpConnection.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/OrthancHttpConnection.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/Orthanc/Stone/OrthancHttpConnection.h --- a/Resources/Orthanc/Stone/OrthancHttpConnection.h Wed Jul 12 18:22:14 2023 +0200 +++ b/Resources/Orthanc/Stone/OrthancHttpConnection.h Wed Jul 12 21:23:14 2023 +0200 @@ -2,8 +2,8 @@ * Stone of Orthanc * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics * Department, University Hospital of Liege, Belgium - * Copyright (C) 2017-2022 Osimis S.A., Belgium - * Copyright (C) 2021-2022 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2021-2023 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 diff -r b5b9719ef1c0 -r a43c2e6abce6 Resources/TestIIIFTiles.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Resources/TestIIIFTiles.py Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import requests +import sys + +if len(sys.argv) != 2: + print('Usage: %s ' % sys.argv[0]) + exit(-1) + +r = requests.get(sys.argv[1]) +r.raise_for_status() + +info = r.json() + +assert(len(info['tiles']) == 1) +assert(len(info['tiles'][0]['scaleFactors']) == len(info['sizes'])) + +width = None +height = None +for size in info['sizes']: + if (width == None or + size['width'] > width): + width = size['width'] + height = size['height'] + +tw = info['tiles'][0]['width'] +th = info['tiles'][0]['height'] + +assert(isinstance(width, int)) +assert(isinstance(height, int)) +assert(isinstance(tw, int)) +assert(isinstance(th, int)) + +def CeilingDivision(a, b): + if a % b == 0: + return a // b + else: + return a // b + 1 + +for s in info['tiles'][0]['scaleFactors']: + assert(isinstance(s, int)) + + countTilesX = CeilingDivision(width, tw * s) + countTilesY = CeilingDivision(height, th * s) + print(tw * s, th * s, countTilesX, countTilesY) + + for m in range(countTilesY): + for n in range(countTilesX): + + # Reference: + # https://iiif.io/api/image/3.0/implementation/#3-tile-region-parameter-calculation + + # Calculate region parameters /xr,yr,wr,hr/ + xr = n * tw * s + yr = m * th * s + wr = tw * s + if (xr + wr > width): + wr = width - xr + hr = th * s + if (yr + hr > height): + hr = height - yr + + # Calculate size parameters /ws,hs/ + ws = tw + if (xr + tw*s > width): + ws = (width - xr + s - 1) / s # +s-1 in numerator to round up + hs = th + if (yr + th*s > height): + hs = (height - yr + s - 1) / s + + url = '%s/%d,%d,%d,%d/%d,%d/0/default.jpg' % (info['id'], xr, yr, wr, hr, ws, hs) + r = requests.get(url) + + if r.status_code == 200: + print('SUCCESS: %s' % url) + else: + print('ERROR: %s' % url) diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/CMakeLists.txt --- a/ViewerPlugin/CMakeLists.txt Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/CMakeLists.txt Wed Jul 12 21:23:14 2023 +0200 @@ -170,9 +170,11 @@ EmbedResources( ${OPENLAYERS_RESOURCES} - ORTHANC_EXPLORER ${CMAKE_SOURCE_DIR}/OrthancExplorer.js - VIEWER_HTML ${CMAKE_SOURCE_DIR}/viewer.html - VIEWER_JS ${CMAKE_SOURCE_DIR}/viewer.js + ORTHANC_EXPLORER ${CMAKE_SOURCE_DIR}/OrthancExplorer.js + VIEWER_HTML ${CMAKE_SOURCE_DIR}/viewer.html + VIEWER_JS ${CMAKE_SOURCE_DIR}/viewer.js + MIRADOR_HTML ${CMAKE_SOURCE_DIR}/mirador.html + OPEN_SEADRAGON_HTML ${CMAKE_SOURCE_DIR}/openseadragon.html ) @@ -183,8 +185,10 @@ set(ORTHANC_WSI_SOURCES DicomPyramidCache.cpp + IIIF.cpp OrthancPluginConnection.cpp Plugin.cpp + RawTile.cpp ${ORTHANC_WSI_DIR}/Framework/DicomToolbox.cpp ${ORTHANC_WSI_DIR}/Framework/Enumerations.cpp diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/DicomPyramidCache.cpp --- a/ViewerPlugin/DicomPyramidCache.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/DicomPyramidCache.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -23,10 +23,15 @@ #include "../Framework/PrecompiledHeadersWSI.h" #include "DicomPyramidCache.h" +#include "OrthancPluginConnection.h" + #include // For std::unique_ptr #include +static std::unique_ptr singleton_; + + namespace OrthancWSI { DicomPyramid* DicomPyramidCache::GetCachedPyramid(const std::string& seriesId) @@ -67,8 +72,9 @@ // time-consuming operation, we don't want it to block other clients) lock.unlock(); + assert(orthanc_.get() != NULL); std::unique_ptr pyramid - (new DicomPyramid(orthanc_, seriesId, true /* use metadata cache */)); + (new DicomPyramid(*orthanc_, seriesId, true /* use metadata cache */)); { // The pyramid is constructed: Store it into the cache @@ -112,11 +118,15 @@ } - DicomPyramidCache::DicomPyramidCache(OrthancStone::IOrthancConnection& orthanc, + DicomPyramidCache::DicomPyramidCache(OrthancStone::IOrthancConnection* orthanc /* takes ownership */, size_t maxSize) : orthanc_(orthanc), maxSize_(maxSize) { + if (orthanc == NULL) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer); + } } @@ -135,6 +145,45 @@ } + void DicomPyramidCache::InitializeInstance(size_t maxSize) + { + if (singleton_.get() == NULL) + { + singleton_.reset(new DicomPyramidCache(new OrthancWSI::OrthancPluginConnection, maxSize)); + } + else + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls); + } + } + + + void DicomPyramidCache::FinalizeInstance() + { + if (singleton_.get() == NULL) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls); + } + else + { + singleton_.reset(NULL); + } + } + + + DicomPyramidCache& DicomPyramidCache::GetInstance() + { + if (singleton_.get() == NULL) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls); + } + else + { + return *singleton_; + } + } + + void DicomPyramidCache::Invalidate(const std::string& seriesId) { boost::mutex::scoped_lock lock(mutex_); @@ -151,10 +200,10 @@ } - DicomPyramidCache::Locker::Locker(DicomPyramidCache& cache, - const std::string& seriesId) : - lock_(cache.mutex_), - pyramid_(cache.GetPyramid(seriesId, lock_)) + DicomPyramidCache::Locker::Locker(const std::string& seriesId) : + cache_(DicomPyramidCache::GetInstance()), + lock_(cache_.mutex_), + pyramid_(cache_.GetPyramid(seriesId, lock_)) { } } diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/DicomPyramidCache.h --- a/ViewerPlugin/DicomPyramidCache.h Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/DicomPyramidCache.h Wed Jul 12 21:23:14 2023 +0200 @@ -27,6 +27,8 @@ #include #include +#include + namespace OrthancWSI { @@ -35,11 +37,14 @@ private: typedef Orthanc::LeastRecentlyUsedIndex Cache; - boost::mutex mutex_; - OrthancStone::IOrthancConnection& orthanc_; - size_t maxSize_; - Cache cache_; + std::unique_ptr orthanc_; + boost::mutex mutex_; + size_t maxSize_; + Cache cache_; + + DicomPyramidCache(OrthancStone::IOrthancConnection* orthanc /* takes ownership */, + size_t maxSize); DicomPyramid* GetCachedPyramid(const std::string& seriesId); @@ -47,22 +52,25 @@ boost::mutex::scoped_lock& lock); public: - DicomPyramidCache(OrthancStone::IOrthancConnection& orthanc, - size_t maxSize); + ~DicomPyramidCache(); + + static void InitializeInstance(size_t maxSize); - ~DicomPyramidCache(); + static void FinalizeInstance(); + + static DicomPyramidCache& GetInstance(); void Invalidate(const std::string& seriesId); class Locker : public boost::noncopyable { private: + DicomPyramidCache& cache_; boost::mutex::scoped_lock lock_; DicomPyramid& pyramid_; public: - Locker(DicomPyramidCache& cache, - const std::string& seriesId); + Locker(const std::string& seriesId); DicomPyramid& GetPyramid() const { diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/IIIF.cpp --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/IIIF.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,624 @@ +/** + * 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) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero 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 + * Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + **/ + + +#include "../Framework/PrecompiledHeadersWSI.h" +#include "IIIF.h" + +#include "DicomPyramidCache.h" +#include "RawTile.h" +#include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h" + +#include +#include +#include +#include + +#include + + +static const char* const ROWS = "0028,0010"; +static const char* const COLUMNS = "0028,0011"; + + +static std::string iiifPublicUrl_; +static bool iiifForcePowersOfTwoScaleFactors_ = false; + + +static void ServeIIIFTiledImageInfo(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + const std::string seriesId(request->groups[0]); + + LOG(INFO) << "IIIF: Image API call to whole-slide pyramid of series " << seriesId; + + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); + const OrthancWSI::ITiledPyramid& pyramid = locker.GetPyramid(); + + if (pyramid.GetLevelCount() == 0) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + if (pyramid.GetTileWidth(0) != pyramid.GetTileHeight(0)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_IncompatibleImageFormat, + "IIIF doesn't support non-isotropic tile sizes"); + } + + for (unsigned int i = 1; i < pyramid.GetLevelCount(); i++) + { + if (pyramid.GetTileWidth(i) != pyramid.GetTileWidth(0) || + pyramid.GetTileHeight(i) != pyramid.GetTileHeight(0)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_IncompatibleImageFormat, + "IIIF doesn't support levels with varying tile sizes"); + } + } + + Json::Value sizes = Json::arrayValue; + Json::Value scaleFactors = Json::arrayValue; + + unsigned int power = 1; + + for (unsigned int i = 0; i < pyramid.GetLevelCount(); i++) + { + /** + * According to the IIIF Image API 3.0 specification, + * "scaleFactors" is: "The set of resolution scaling factors for + * the image's predefined tiles, expressed as POSITIVE INTEGERS by + * which to divide the full size of the image. For example, a + * scale factor of 4 indicates that the service can efficiently + * deliver images at 1/4 or 25% of the height and width of the + * full image." => We can only serve the levels for which the full + * width/height of the image is divisible by the width/height of + * the level. + **/ + if (pyramid.GetLevelWidth(0) % pyramid.GetLevelWidth(i) == 0 && + pyramid.GetLevelHeight(0) % pyramid.GetLevelHeight(i) == 0) + { + unsigned int scaleFactor = pyramid.GetLevelWidth(0) / pyramid.GetLevelWidth(i); + + if (!iiifForcePowersOfTwoScaleFactors_ || + scaleFactor == power) + { + Json::Value level; + level["width"] = pyramid.GetLevelWidth(i); + level["height"] = pyramid.GetLevelHeight(i); + sizes.append(level); + + scaleFactors.append(scaleFactor); + + power *= 2; + } + else + { + LOG(WARNING) << "IIIF - Dropping level " << i << " of series " << seriesId + << ", as it doesn't follow the powers-of-two pattern"; + } + } + else + { + LOG(WARNING) << "IIIF - Dropping level " << i << " of series " << seriesId + << ", as the full width/height (" + << pyramid.GetLevelWidth(0) << "x" << pyramid.GetLevelHeight(0) + << ") of the image is not an integer multiple of the level width/height (" + << pyramid.GetLevelWidth(i) << "x" << pyramid.GetLevelHeight(i) << ")"; + } + } + + Json::Value tiles; + tiles["width"] = pyramid.GetTileWidth(0); + tiles["height"] = pyramid.GetTileHeight(0); + tiles["scaleFactors"] = scaleFactors; + + Json::Value result; + result["@context"] = "http://iiif.io/api/image/3/context.json"; + result["profile"] = "level0"; + result["protocol"] = "http://iiif.io/api/image"; + result["type"] = "ImageService3"; + + result["id"] = iiifPublicUrl_ + "tiles/" + seriesId; + result["width"] = pyramid.GetLevelWidth(0); + result["height"] = pyramid.GetLevelHeight(0); + result["sizes"] = sizes; + result["tiles"].append(tiles); + + std::string s = result.toStyledString(); + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Json)); +} + + +static unsigned int GetPhysicalTileWidth(const OrthancWSI::ITiledPyramid& pyramid, + unsigned int level) +{ + return static_cast(boost::math::iround( + static_cast(pyramid.GetTileWidth(level)) * + static_cast(pyramid.GetLevelWidth(0)) / + static_cast(pyramid.GetLevelWidth(level)))); +} + + +static unsigned int GetPhysicalTileHeight(const OrthancWSI::ITiledPyramid& pyramid, + unsigned int level) +{ + return static_cast(boost::math::iround( + static_cast(pyramid.GetTileHeight(level)) * + static_cast(pyramid.GetLevelHeight(0)) / + static_cast(pyramid.GetLevelHeight(level)))); +} + + +static void ServeIIIFTiledImageTile(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + const std::string seriesId(request->groups[0]); + const std::string region(request->groups[1]); + const std::string size(request->groups[2]); + const std::string rotation(request->groups[3]); + const std::string quality(request->groups[4]); + const std::string format(request->groups[5]); + + LOG(INFO) << "IIIF: Image API call to tile of series " << seriesId << ": " + << "region=" << region << "; size=" << size << "; rotation=" + << rotation << "; quality=" << quality << "; format=" << format; + + if (rotation != "0") + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported rotation: " + rotation); + } + + if (quality != "default") + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported quality: " + quality); + } + + if (format != "jpg") + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported format: " + format); + } + + if (region == "full") + { + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); + + OrthancWSI::ITiledPyramid& pyramid = locker.GetPyramid(); + const unsigned int level = pyramid.GetLevelCount() - 1; + + Orthanc::Image full(Orthanc::PixelFormat_RGB24, pyramid.GetLevelWidth(level), pyramid.GetLevelHeight(level), false); + Orthanc::ImageProcessing::Set(full, 255, 255, 255, 0); + + const unsigned int nx = OrthancWSI::CeilingDivision(pyramid.GetLevelWidth(level), pyramid.GetTileWidth(level)); + const unsigned int ny = OrthancWSI::CeilingDivision(pyramid.GetLevelHeight(level), pyramid.GetTileHeight(level)); + for (unsigned int ty = 0; ty < ny; ty++) + { + const unsigned int y = ty * pyramid.GetTileHeight(level); + const unsigned int height = std::min(pyramid.GetTileHeight(level), full.GetHeight() - y); + + for (unsigned int tx = 0; tx < nx; tx++) + { + const unsigned int x = tx * pyramid.GetTileWidth(level); + std::unique_ptr tile(pyramid.DecodeTile(level, tx, ty)); + + const unsigned int width = std::min(pyramid.GetTileWidth(level), full.GetWidth() - x); + + Orthanc::ImageAccessor source, target; + tile->GetRegion(source, 0, 0, width, height); + full.GetRegion(target, x, y, width, height); + + Orthanc::ImageProcessing::Copy(target, source); + } + } + + std::string encoded; + OrthancWSI::RawTile::Encode(encoded, full, Orthanc::MimeType_Jpeg); + + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, encoded.c_str(), + encoded.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg)); + } + else + { + std::vector tokens; + Orthanc::Toolbox::TokenizeString(tokens, region, ','); + + uint32_t regionX, regionY, regionWidth, regionHeight; + + if (tokens.size() != 4 || + !Orthanc::SerializationToolbox::ParseUnsignedInteger32(regionX, tokens[0]) || + !Orthanc::SerializationToolbox::ParseUnsignedInteger32(regionY, tokens[1]) || + !Orthanc::SerializationToolbox::ParseUnsignedInteger32(regionWidth, tokens[2]) || + !Orthanc::SerializationToolbox::ParseUnsignedInteger32(regionHeight, tokens[3])) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Invalid (x,y,width,height) region, found: " + region); + } + + uint32_t cropWidth, cropHeight; + + Orthanc::Toolbox::TokenizeString(tokens, size, ','); + + bool ok = false; + if (tokens.size() == 2 && + Orthanc::SerializationToolbox::ParseUnsignedInteger32(cropWidth, tokens[0])) + { + if (tokens[1].empty()) + { + cropHeight = cropWidth; + ok = true; + } + else if (Orthanc::SerializationToolbox::ParseUnsignedInteger32(cropHeight, tokens[1])) + { + ok = true; + } + } + + if (!ok) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Invalid (width,height) crop, found: " + size); + } + + std::unique_ptr rawTile; + std::unique_ptr toCrop; + + { + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); + + OrthancWSI::ITiledPyramid& pyramid = locker.GetPyramid(); + + unsigned int level; + for (level = 0; level < pyramid.GetLevelCount(); level++) + { + const unsigned int physicalTileWidth = GetPhysicalTileWidth(pyramid, level); + const unsigned int physicalTileHeight = GetPhysicalTileHeight(pyramid, level); + + if (regionX % physicalTileWidth == 0 && + regionY % physicalTileHeight == 0 && + static_cast(regionWidth) <= physicalTileWidth && + static_cast(regionHeight) <= physicalTileHeight && + static_cast(regionX + regionWidth) <= pyramid.GetLevelWidth(0) && + static_cast(regionY + regionHeight) <= pyramid.GetLevelHeight(0)) + { + break; + } + } + + if (level == pyramid.GetLevelCount()) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "IIIF - Cannot locate the level of interest"); + } + else if (static_cast(cropWidth) > pyramid.GetTileWidth(level)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "IIIF - Request for a cropping that is too large for the tile size"); + } + else + { + rawTile.reset(new OrthancWSI::RawTile(locker.GetPyramid(), level, + regionX / GetPhysicalTileWidth(pyramid, level), + regionY / GetPhysicalTileHeight(pyramid, level))); + + if (static_cast(cropWidth) < pyramid.GetTileWidth(level) || + static_cast(cropHeight) < pyramid.GetTileHeight(level)) + { + toCrop.reset(rawTile->Decode()); + rawTile.reset(NULL); + } + } + } + + if (rawTile.get() != NULL) + { + assert(toCrop.get() == NULL); + + // Level 0 Compliance of IIIF expects JPEG files + rawTile->Answer(output, Orthanc::MimeType_Jpeg); + } + else if (toCrop.get() != NULL) + { + assert(rawTile.get() == NULL); + + if (static_cast(cropWidth) > toCrop->GetWidth() || + static_cast(cropHeight) > toCrop->GetHeight()) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "IIIF - Asking to crop outside of the tile size"); + } + + Orthanc::ImageAccessor cropped; + toCrop->GetRegion(cropped, 0, 0, cropWidth, cropHeight); + + std::string encoded; + OrthancWSI::RawTile::Encode(encoded, cropped, Orthanc::MimeType_Jpeg); + + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, encoded.c_str(), + encoded.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg)); + } + else + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + } +} + + +static void AddCanvas(Json::Value& manifest, + const std::string& seriesId, + const std::string& imageService, + unsigned int page, + unsigned int width, + unsigned int height, + const std::string& description) +{ + const std::string base = iiifPublicUrl_ + seriesId; + + Json::Value service; + service["id"] = iiifPublicUrl_ + imageService; + service["profile"] = "level0"; + service["type"] = "ImageService3"; + + Json::Value body; + body["id"] = iiifPublicUrl_ + imageService + "/full/max/0/default.jpg"; + body["type"] = "Image"; + body["format"] = Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg); + body["height"] = height; + body["width"] = width; + body["service"].append(service); + + Json::Value annotation; + annotation["id"] = base + "/annotation/p" + boost::lexical_cast(page) + "-image"; + annotation["type"] = "Annotation"; + annotation["motivation"] = "painting"; + annotation["body"] = body; + annotation["target"] = base + "/canvas/p" + boost::lexical_cast(page); + + Json::Value annotationPage; + annotationPage["id"] = base + "/page/p" + boost::lexical_cast(page) + "/1"; + annotationPage["type"] = "AnnotationPage"; + annotationPage["items"].append(annotation); + + Json::Value canvas; + canvas["id"] = annotation["target"]; + canvas["type"] = "Canvas"; + canvas["width"] = width; + canvas["height"] = height; + canvas["label"]["en"].append(description); + canvas["items"].append(annotationPage); + + manifest["items"].append(canvas); +} + + +static void ServeIIIFManifest(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + static const char* const KEY_INSTANCES = "Instances"; + static const char* const SOP_CLASS_UID = "0008,0016"; + static const char* const SLICES_SHORT = "SlicesShort"; + + const std::string seriesId(request->groups[0]); + + LOG(INFO) << "IIIF: Presentation API call to series " << seriesId; + + Json::Value study, series; + if (!OrthancPlugins::RestApiGet(series, "/series/" + seriesId, false) || + !OrthancPlugins::RestApiGet(study, "/series/" + seriesId + "/study", false)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + if (study.type() != Json::objectValue || + series.type() != Json::objectValue || + !series.isMember(KEY_INSTANCES) || + series[KEY_INSTANCES].type() != Json::arrayValue || + series[KEY_INSTANCES].size() == 0u || + series[KEY_INSTANCES][0].type() != Json::stringValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + Json::Value oneInstance; + if (!OrthancPlugins::RestApiGet(oneInstance, "/instances/" + series[KEY_INSTANCES][0].asString() + "/tags?short", false) || + oneInstance.type() != Json::objectValue || + !oneInstance.isMember(SOP_CLASS_UID) || + oneInstance[SOP_CLASS_UID].type() != Json::stringValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + const std::string sopClassUid = Orthanc::Toolbox::StripSpaces(oneInstance[SOP_CLASS_UID].asString()); + + const std::string base = iiifPublicUrl_ + "series/" + seriesId; + + Json::Value manifest; + manifest["@context"] = "http://iiif.io/api/presentation/3/context.json"; + manifest["id"] = base + "/manifest.json"; + manifest["type"] = "Manifest"; + manifest["label"]["en"].append(study["MainDicomTags"]["StudyDate"].asString() + " - " + + series["MainDicomTags"]["Modality"].asString() + " - " + + study["MainDicomTags"]["StudyDescription"].asString() + " - " + + series["MainDicomTags"]["SeriesDescription"].asString()); + + if (sopClassUid == OrthancWSI::VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE_IOD) + { + /** + * This is based on IIIF cookbook: "Support Deep Viewing with Basic + * Use of a IIIF Image Service." + * https://iiif.io/api/cookbook/recipe/0005-image-service/ + **/ + unsigned int width, height; + + { + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); + width = locker.GetPyramid().GetLevelWidth(0); + height = locker.GetPyramid().GetLevelHeight(0); + } + + AddCanvas(manifest, seriesId, "tiles/" + seriesId, 1, width, height, ""); + } + else + { + /** + * This is based on IIIF cookbook: "Simple Manifest - Book" + * https://iiif.io/api/cookbook/recipe/0009-book-1/ + **/ + + manifest["behavior"].append("individuals"); + + uint32_t width, height; + if (!oneInstance.isMember(COLUMNS) || + !oneInstance.isMember(ROWS) || + oneInstance[COLUMNS].type() != Json::stringValue || + oneInstance[ROWS].type() != Json::stringValue || + !Orthanc::SerializationToolbox::ParseFirstUnsignedInteger32(width, oneInstance[COLUMNS].asString()) || + !Orthanc::SerializationToolbox::ParseFirstUnsignedInteger32(height, oneInstance[ROWS].asString())) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + Json::Value orderedSlices; + if (!OrthancPlugins::RestApiGet(orderedSlices, "/series/" + seriesId + "/ordered-slices", false)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + if (orderedSlices.type() != Json::objectValue || + !orderedSlices.isMember(SLICES_SHORT) || + orderedSlices[SLICES_SHORT].type() != Json::arrayValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + const Json::Value& slicesShort = orderedSlices[SLICES_SHORT]; + + unsigned int page = 1; + for (Json::ArrayIndex instance = 0; instance < slicesShort.size(); instance++) + { + if (slicesShort[instance].type() != Json::arrayValue || + slicesShort[instance].size() != 3u || + slicesShort[instance][0].type() != Json::stringValue || + slicesShort[instance][1].type() != Json::intValue || + slicesShort[instance][2].type() != Json::intValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + const std::string instanceId = slicesShort[instance][0].asString(); + const unsigned int start = slicesShort[instance][1].asUInt(); + const unsigned int count = slicesShort[instance][2].asUInt(); + + for (unsigned int frame = start; frame < start + count; frame++, page++) + { + AddCanvas(manifest, instanceId, "frames/" + instanceId + "/" + boost::lexical_cast(frame), + page, width, height, ""); + } + } + } + + std::string s = manifest.toStyledString(); + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Json)); +} + + +static void ServeIIIFFrameInfo(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + const std::string instanceId(request->groups[0]); + const std::string frame(request->groups[1]); + + LOG(INFO) << "IIIF: Image API call to manifest of instance " << instanceId << " at frame " << frame; + + Json::Value instance; + if (!OrthancPlugins::RestApiGet(instance, "/instances/" + instanceId + "/tags?short", false)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + uint32_t width, height; + if (!instance.isMember(COLUMNS) || + !instance.isMember(ROWS) || + instance[COLUMNS].type() != Json::stringValue || + instance[ROWS].type() != Json::stringValue || + !Orthanc::SerializationToolbox::ParseFirstUnsignedInteger32(width, instance[COLUMNS].asString()) || + !Orthanc::SerializationToolbox::ParseFirstUnsignedInteger32(height, instance[ROWS].asString())) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + Json::Value tile; + tile["height"] = height; + tile["width"] = width; + tile["scaleFactors"].append(1); + + Json::Value result; + result["@context"] = "http://iiif.io/api/image/3/context.json"; + result["profile"] = "http://iiif.io/api/image/3/level0.json"; + result["protocol"] = "http://iiif.io/api/image"; + result["type"] = "ImageService3"; + + result["id"] = iiifPublicUrl_ + "frames/" + instanceId + "/" + frame; + result["width"] = width; + result["height"] = height; + result["tiles"].append(tile); + + std::string s = result.toStyledString(); + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Json)); +} + + +static void ServeIIIFFrameImage(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + const std::string instanceId(request->groups[0]); + const std::string frame(request->groups[1]); + + LOG(INFO) << "IIIF: Image API call to JPEG of instance " << instanceId << " at frame " << frame; + + std::map httpHeaders; + httpHeaders["Accept"] = Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg); + + std::string jpeg; + if (!OrthancPlugins::RestApiGetString(jpeg, "/instances/" + instanceId + "/frames/" + frame + "/preview", httpHeaders, false)) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, jpeg.empty() ? NULL : jpeg.c_str(), + jpeg.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg)); +} + + +void InitializeIIIF(const std::string& iiifPublicUrl) +{ + iiifPublicUrl_ = iiifPublicUrl; + + OrthancPlugins::RegisterRestCallback("/wsi/iiif/tiles/([0-9a-f-]+)/info.json", true); + OrthancPlugins::RegisterRestCallback("/wsi/iiif/tiles/([0-9a-f-]+)/([0-9a-z,:]+)/([0-9a-z,!:]+)/([0-9,!]+)/([a-z]+)\\.([a-z]+)", true); + OrthancPlugins::RegisterRestCallback("/wsi/iiif/series/([0-9a-f-]+)/manifest.json", true); + OrthancPlugins::RegisterRestCallback("/wsi/iiif/frames/([0-9a-f-]+)/([0-9]+)/info.json", true); + OrthancPlugins::RegisterRestCallback("/wsi/iiif/frames/([0-9a-f-]+)/([0-9]+)/full/max/0/default.jpg", true); +} + +void SetIIIFForcePowersOfTwoScaleFactors(bool force) +{ + iiifForcePowersOfTwoScaleFactors_ = force; +} diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/IIIF.h --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/IIIF.h Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,34 @@ +/** + * 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) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero 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 + * Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + **/ + + +#pragma once + +#include + +void InitializeIIIF(const std::string& iiifPublicUrl); + +/** + * Filter pyramids whose level sizes don't follow a powers-of-two + * pattern. This can be used to bypass issue 2379 on OpenSeadragon <= + * 4.1: https://github.com/openseadragon/openseadragon/issues/2379 + **/ +void SetIIIFForcePowersOfTwoScaleFactors(bool force); diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/OrthancExplorer.js --- a/ViewerPlugin/OrthancExplorer.js Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/OrthancExplorer.js Wed Jul 12 21:23:14 2023 +0200 @@ -23,8 +23,13 @@ $('#series').live('pagebeforeshow', function() { var seriesId = $.mobile.pageData.uuid; + $('#mirador-button').remove(); + $('#openseadragon-button').remove(); $('#wsi-button').remove(); + $('#series-iiif-button').remove(); + $('#series-access').listview("refresh"); + // Test whether this is a whole-slide image by check the SOP Class // UID of one instance of the series GetResource('/series/' + seriesId, function(series) { @@ -50,6 +55,64 @@ } }); + if (${SERVE_OPEN_SEADRAGON}) { + var b = $('') + .attr('id', 'openseadragon-button') + .attr('data-role', 'button') + .attr('href', '#') + .attr('data-icon', 'search') + .attr('data-theme', 'e') + .text('Test IIIF in OpenSeadragon') + .button(); + + b.insertAfter($('#series-info')); + b.click(function() { + if ($.mobile.pageData) { + window.open('../wsi/app/openseadragon.html?image=../iiif/tiles/' + seriesId + '/info.json'); + } + }); + } + } + + if (${ENABLE_IIIF}) { + var b = $('') + .attr('data-role', 'button') + .attr('href', '#') + .text('Copy link to IIIF manifest'); + + var li = $('
  • ') + .attr('id', 'series-iiif-button') + .attr('data-icon', 'gear') + .append(b); + + $('#series-access').append(li).listview("refresh"); + + b.click(function(e) { + if ($.mobile.pageData) { + e.preventDefault(); + var url = new URL('../wsi/iiif/series/' + seriesId + '/manifest.json', window.location.href); + navigator.clipboard.writeText(url.href); + $(e.target).closest('li').buttonMarkup({ icon: 'check' }); + } + }); + } + + if (${SERVE_MIRADOR}) { + var b = $('') + .attr('id', 'mirador-button') + .attr('data-role', 'button') + .attr('href', '#') + .attr('data-icon', 'search') + .attr('data-theme', 'e') + .text('Test IIIF in Mirador') + .button(); + + b.insertAfter($('#series-info')); + b.click(function() { + if ($.mobile.pageData) { + window.open('../wsi/app/mirador.html?iiif-content=../iiif/series/' + seriesId + '/manifest.json'); + } + }); } }); }); diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/OrthancPluginConnection.cpp --- a/ViewerPlugin/OrthancPluginConnection.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/OrthancPluginConnection.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -20,6 +20,7 @@ **/ +#include "../Framework/PrecompiledHeadersWSI.h" #include "OrthancPluginConnection.h" #include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h" diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/Plugin.cpp --- a/ViewerPlugin/Plugin.cpp Wed Jul 12 18:22:14 2023 +0200 +++ b/ViewerPlugin/Plugin.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -22,16 +22,14 @@ #include "../Framework/PrecompiledHeadersWSI.h" -#include "../Framework/ImageToolbox.h" -#include "../Framework/Jpeg2000Reader.h" #include "DicomPyramidCache.h" -#include "OrthancPluginConnection.h" +#include "IIIF.h" +#include "RawTile.h" #include // For std::unique_ptr -#include +#include #include -#include -#include +#include #include #include @@ -41,11 +39,6 @@ #include -std::unique_ptr orthanc_; -std::unique_ptr cache_; -std::unique_ptr transcoderSemaphore_; - - static void AnswerSparseTile(OrthancPluginRestOutput* output, unsigned int tileWidth, unsigned int tileHeight) @@ -86,7 +79,7 @@ OrthancPluginLogInfo(OrthancPlugins::GetGlobalContext(), tmp); - OrthancWSI::DicomPyramidCache::Locker locker(*cache_, seriesId); + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); unsigned int totalWidth = locker.GetPyramid().GetLevelWidth(0); unsigned int totalHeight = locker.GetPyramid().GetLevelHeight(0); @@ -155,88 +148,85 @@ } // Retrieve the raw tile from the WSI pyramid - OrthancWSI::ImageCompression compression; - Orthanc::PhotometricInterpretation photometric; - Orthanc::PixelFormat format; - std::string tile; - unsigned int tileWidth, tileHeight; + std::unique_ptr rawTile; { - OrthancWSI::DicomPyramidCache::Locker locker(*cache_, seriesId); + // NB: Don't call "rawTile" while the Locker is around, as + // "Answer()" can be a costly operation. + OrthancWSI::DicomPyramidCache::Locker locker(seriesId); + + rawTile.reset(new OrthancWSI::RawTile(locker.GetPyramid(), + static_cast(level), + static_cast(tileX), + static_cast(tileY))); + } + + Orthanc::MimeType mime; - format = locker.GetPyramid().GetPixelFormat(); - tileWidth = locker.GetPyramid().GetTileWidth(level); - tileHeight = locker.GetPyramid().GetTileHeight(level); - photometric = locker.GetPyramid().GetPhotometricInterpretation(); + if (rawTile->GetCompression() == OrthancWSI::ImageCompression_Jpeg) + { + // The tile is already a JPEG image. In such a case, we can + // serve it as such, because any Web browser can handle JPEG. + mime = Orthanc::MimeType_Jpeg; + } + else + { + // This is a lossless frame (coming from JPEG2000 or uncompressed + // DICOM instance), not a DICOM-JPEG instance. Decompress the raw + // tile, then transcode it to PNG to prevent lossy compression and + // to avoid JPEG2000 that is not supported by all the browsers. + mime = Orthanc::MimeType_Png; + } + + // Lookup whether a "Accept" HTTP header is present, to overwrite + // the default MIME type + for (uint32_t i = 0; i < request->headersCount; i++) + { + std::string key(request->headersKeys[i]); + Orthanc::Toolbox::ToLowerCase(key); - if (!locker.GetPyramid().ReadRawTile(tile, compression, - static_cast(level), - static_cast(tileX), - static_cast(tileY))) + if (key == "accept") { - // Handling of missing tile (for sparse tiling): TODO parameter? - // AnswerSparseTile(output, tileWidth, tileHeight); return; - throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + std::vector tokens; + Orthanc::Toolbox::TokenizeString(tokens, request->headersValues[i], ','); + + bool found = false; + + for (size_t j = 0; j < tokens.size(); j++) + { + std::string s = Orthanc::Toolbox::StripSpaces(tokens[j]); + + if (s == Orthanc::EnumerationToString(Orthanc::MimeType_Png)) + { + mime = Orthanc::MimeType_Png; + found = true; + } + else if (s == Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg)) + { + mime = Orthanc::MimeType_Jpeg; + found = true; + } + else if (s == Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg2000)) + { + mime = Orthanc::MimeType_Jpeg2000; + found = true; + } + else if (s == "*/*" || + s == "image/*") + { + found = true; + } + } + + if (!found) + { + OrthancPluginSendHttpStatusCode(OrthancPlugins::GetGlobalContext(), output, 406 /* Not acceptable */); + return; + } } } - - // Test whether the tile is a JPEG image. In such a case, we can - // serve it as such, because any Web browser can handle JPEG - - if (compression == OrthancWSI::ImageCompression_Jpeg) - { - OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, tile.c_str(), tile.size(), "image/jpeg"); - return; // We're done - } - - - // The tile does not come from a DICOM-JPEG instance, we need to - // decompress the raw tile - std::unique_ptr decoded; - - Orthanc::Semaphore::Locker locker(*transcoderSemaphore_); - - switch (compression) - { - case OrthancWSI::ImageCompression_Jpeg2000: - decoded.reset(new OrthancWSI::Jpeg2000Reader); - dynamic_cast(*decoded).ReadFromMemory(tile); - - if (photometric == Orthanc::PhotometricInterpretation_YBR_ICT) - { - OrthancWSI::ImageToolbox::ConvertJpegYCbCrToRgb(*decoded); - } - - break; - - case OrthancWSI::ImageCompression_None: - { - unsigned int bpp = Orthanc::GetBytesPerPixel(format); - if (bpp * tileWidth * tileHeight != tile.size()) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat); - } - - decoded.reset(new Orthanc::ImageAccessor); - decoded->AssignReadOnly(format, tileWidth, tileHeight, bpp * tileWidth, tile.c_str()); - break; - } - - default: - throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented); - } - - - // This is a lossless frame (coming from a JPEG2000 or uncompressed - // DICOM instance), serve it as a PNG image so as to prevent lossy - // compression - - std::string png; - Orthanc::PngWriter writer; - Orthanc::IImageWriter::WriteToMemory(writer, png, *decoded); - - OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, png.c_str(), png.size(), "image/png"); + rawTile->Answer(output, mime); } @@ -251,7 +241,7 @@ sprintf(tmp, "New instance has been added to series %s, invalidating it", resourceId); OrthancPluginLogInfo(OrthancPlugins::GetGlobalContext(), tmp); - cache_->Invalidate(resourceId); + OrthancWSI::DicomPyramidCache::GetInstance().Invalidate(resourceId); } return OrthancPluginErrorCode_Success; @@ -288,6 +278,16 @@ resource = Orthanc::EmbeddedResources::OPENLAYERS_CSS; mime = "text/css"; } + else if (f == "mirador.html") + { + resource = Orthanc::EmbeddedResources::MIRADOR_HTML; + mime = "text/html"; + } + else if (f == "openseadragon.html") + { + resource = Orthanc::EmbeddedResources::OPEN_SEADRAGON_HTML; + mime = "text/html"; + } else { throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); @@ -300,7 +300,6 @@ } - extern "C" { ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context) @@ -337,7 +336,7 @@ // hardware threads (e.g. number of CPUs or cores or // hyperthreading units) unsigned int threads = Orthanc::SystemToolbox::GetHardwareConcurrency(); - transcoderSemaphore_.reset(new Orthanc::Semaphore(threads)); + OrthancWSI::RawTile::InitializeTranscoderSemaphore(threads); char info[1024]; sprintf(info, "The whole-slide imaging plugin will use at most %u threads to transcode the tiles", threads); @@ -345,8 +344,7 @@ OrthancPluginSetDescription(context, "Provides a Web viewer of whole-slide microscopic images within Orthanc."); - orthanc_.reset(new OrthancWSI::OrthancPluginConnection); - cache_.reset(new OrthancWSI::DicomPyramidCache(*orthanc_, 10 /* Number of pyramids to be cached - TODO parameter */)); + OrthancWSI::DicomPyramidCache::InitializeInstance(10 /* Number of pyramids to be cached - TODO parameter */); OrthancPluginRegisterOnChangeCallback(OrthancPlugins::GetGlobalContext(), OnChangeCallback); @@ -357,10 +355,81 @@ OrthancPlugins::RegisterRestCallback("/wsi/pyramids/([0-9a-f-]+)", true); OrthancPlugins::RegisterRestCallback("/wsi/tiles/([0-9a-f-]+)/([0-9-]+)/([0-9-]+)/([0-9-]+)", true); - // Extend the default Orthanc Explorer with custom JavaScript for WSI - std::string explorer; - Orthanc::EmbeddedResources::GetFileResource(explorer, Orthanc::EmbeddedResources::ORTHANC_EXPLORER); - OrthancPluginExtendOrthancExplorer(OrthancPlugins::GetGlobalContext(), explorer.c_str()); + OrthancPlugins::OrthancConfiguration mainConfiguration; + + OrthancPlugins::OrthancConfiguration wsiConfiguration; + mainConfiguration.GetSection(wsiConfiguration, "WholeSlideImaging"); + + const bool enableIIIF = wsiConfiguration.GetBooleanValue("EnableIIIF", true); + bool serveMirador = false; + bool serveOpenSeadragon = false; + std::string iiifPublicUrl; + + if (enableIIIF) + { + if (!wsiConfiguration.LookupStringValue(iiifPublicUrl, "OrthancPublicURL")) + { + unsigned int port = mainConfiguration.GetUnsignedIntegerValue("HttpPort", 8042); + iiifPublicUrl = "http://localhost:" + boost::lexical_cast(port) + "/"; + } + + if (iiifPublicUrl.empty() || + iiifPublicUrl[iiifPublicUrl.size() - 1] != '/') + { + iiifPublicUrl += "/"; + } + + iiifPublicUrl += "wsi/iiif/"; + + InitializeIIIF(iiifPublicUrl); + + serveMirador = wsiConfiguration.GetBooleanValue("ServeMirador", false); + serveOpenSeadragon = wsiConfiguration.GetBooleanValue("ServeOpenSeadragon", false); + + bool value; + if (wsiConfiguration.LookupBooleanValue(value, "ForcePowersOfTwoScaleFactors")) + { + SetIIIFForcePowersOfTwoScaleFactors(value); + } + else + { + /** + * By default, compatibility mode is disabled. However, if + * Mirador or OSD are enabled, compatibility mode is + * automatically enabled to enhance user experience, at least + * until issue 2379 of OSD is solved: + * https://github.com/openseadragon/openseadragon/issues/2379 + **/ + SetIIIFForcePowersOfTwoScaleFactors(serveMirador || serveOpenSeadragon); + } + } + + LOG(WARNING) << "Support of IIIF is " << (enableIIIF ? "enabled" : "disabled") << " in the whole-slide imaging plugin"; + + if (serveMirador) + { + OrthancPlugins::RegisterRestCallback("/wsi/app/(mirador.html)", true); + } + + if (serveOpenSeadragon) + { + OrthancPlugins::RegisterRestCallback("/wsi/app/(openseadragon.html)", true); + } + + { + // Extend the default Orthanc Explorer with custom JavaScript for WSI + + std::string explorer; + Orthanc::EmbeddedResources::GetFileResource(explorer, Orthanc::EmbeddedResources::ORTHANC_EXPLORER); + + std::map dictionary; + dictionary["ENABLE_IIIF"] = (enableIIIF ? "true" : "false"); + dictionary["SERVE_MIRADOR"] = (serveMirador ? "true" : "false"); + dictionary["SERVE_OPEN_SEADRAGON"] = (serveOpenSeadragon ? "true" : "false"); + explorer = Orthanc::Toolbox::SubstituteVariables(explorer, dictionary); + + OrthancPluginExtendOrthancExplorer(OrthancPlugins::GetGlobalContext(), explorer.c_str()); + } return 0; } @@ -368,9 +437,8 @@ ORTHANC_PLUGINS_API void OrthancPluginFinalize() { - cache_.reset(NULL); - orthanc_.reset(NULL); - transcoderSemaphore_.reset(NULL); + OrthancWSI::DicomPyramidCache::FinalizeInstance(); + OrthancWSI::RawTile::FinalizeTranscoderSemaphore(); } diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/RawTile.cpp --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/RawTile.cpp Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,184 @@ +/** + * 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) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero 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 + * Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + **/ + + +#include "../Framework/PrecompiledHeadersWSI.h" +#include "RawTile.h" + +#include "../Framework/ImageToolbox.h" +#include "../Framework/Jpeg2000Reader.h" +#include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h" + +#include // For std::unique_ptr +#include +#include +#include +#include +#include + + +static std::unique_ptr transcoderSemaphore_; + +namespace OrthancWSI +{ + static ImageCompression Convert(Orthanc::MimeType type) + { + switch (type) + { + case Orthanc::MimeType_Png: + return ImageCompression_Png; + + case Orthanc::MimeType_Jpeg: + return ImageCompression_Jpeg; + + case Orthanc::MimeType_Jpeg2000: + return ImageCompression_Jpeg2000; + + default: + throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange); + } + } + + + Orthanc::ImageAccessor* RawTile::DecodeInternal() + { + switch (compression_) + { + case ImageCompression_Jpeg: + { + std::unique_ptr decoded(new Orthanc::JpegReader); + decoded->ReadFromMemory(tile_); + return decoded.release(); + } + + case ImageCompression_Jpeg2000: + { + std::unique_ptr decoded(new Jpeg2000Reader); + decoded->ReadFromMemory(tile_); + + if (photometric_ == Orthanc::PhotometricInterpretation_YBR_ICT) + { + ImageToolbox::ConvertJpegYCbCrToRgb(*decoded); + } + + return decoded.release(); + } + + case ImageCompression_None: + { + unsigned int bpp = Orthanc::GetBytesPerPixel(format_); + if (bpp * tileWidth_ * tileHeight_ != tile_.size()) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat); + } + + std::unique_ptr decoded(new Orthanc::ImageAccessor); + decoded->AssignReadOnly(format_, tileWidth_, tileHeight_, bpp * tileWidth_, tile_.c_str()); + + return decoded.release(); + } + + default: + throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented); + } + } + + void RawTile::EncodeInternal(std::string& encoded, + const Orthanc::ImageAccessor& decoded, + Orthanc::MimeType encoding) + { + ImageToolbox::EncodeTile(encoded, decoded, Convert(encoding), 90 /* only used for JPEG */); + } + + + RawTile::RawTile(ITiledPyramid& pyramid, + unsigned int level, + unsigned int tileX, + unsigned int tileY) : + format_(pyramid.GetPixelFormat()), + tileWidth_(pyramid.GetTileWidth(level)), + tileHeight_(pyramid.GetTileHeight(level)), + photometric_(pyramid.GetPhotometricInterpretation()) + { + if (!pyramid.ReadRawTile(tile_, compression_, level, tileX, tileY)) + { + // Handling of missing tile (for sparse tiling): TODO parameter? + // AnswerSparseTile(output, tileWidth, tileHeight); return; + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource); + } + } + + + void RawTile::Answer(OrthancPluginRestOutput* output, + Orthanc::MimeType encoding) + { + if ((compression_ == ImageCompression_Jpeg && encoding == Orthanc::MimeType_Jpeg) || + (compression_ == ImageCompression_Jpeg2000 && encoding == Orthanc::MimeType_Jpeg2000)) + { + // No transcoding is needed, the tile can be served as such + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, tile_.c_str(), + tile_.size(), Orthanc::EnumerationToString(encoding)); + } + else + { + std::string transcoded; + + { + // The semaphore is used to throttle the number of simultaneous computations + Orthanc::Semaphore::Locker locker(*transcoderSemaphore_); + + std::unique_ptr decoded(DecodeInternal()); + EncodeInternal(transcoded, *decoded, encoding); + } + + OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, transcoded.c_str(), + transcoded.size(), Orthanc::EnumerationToString(encoding)); + } + } + + + Orthanc::ImageAccessor* RawTile::Decode() + { + Orthanc::Semaphore::Locker locker(*transcoderSemaphore_); + return DecodeInternal(); + } + + + void RawTile::Encode(std::string& encoded, + const Orthanc::ImageAccessor& decoded, + Orthanc::MimeType encoding) + { + Orthanc::Semaphore::Locker locker(*transcoderSemaphore_); + EncodeInternal(encoded, decoded, encoding); + } + + + void RawTile::InitializeTranscoderSemaphore(unsigned int maxThreads) + { + transcoderSemaphore_.reset(new Orthanc::Semaphore(maxThreads)); + } + + + void RawTile::FinalizeTranscoderSemaphore() + { + transcoderSemaphore_.reset(NULL); + } +} diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/RawTile.h --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/RawTile.h Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,75 @@ +/** + * 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) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero 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 + * Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + **/ + + +#pragma once + +#include "../Framework/Enumerations.h" +#include "../Framework/Inputs/ITiledPyramid.h" + +#include + + +namespace OrthancWSI +{ + class RawTile : public boost::noncopyable + { + private: + Orthanc::PixelFormat format_; + unsigned int tileWidth_; + unsigned int tileHeight_; + Orthanc::PhotometricInterpretation photometric_; + std::string tile_; + ImageCompression compression_; + + Orthanc::ImageAccessor* DecodeInternal(); + + static void EncodeInternal(std::string& encoded, + const Orthanc::ImageAccessor& decoded, + Orthanc::MimeType encoding); + + public: + RawTile(ITiledPyramid& pyramid, + unsigned int level, + unsigned int tileX, + unsigned int tileY); + + ImageCompression GetCompression() const + { + return compression_; + } + + void Answer(OrthancPluginRestOutput* output, + Orthanc::MimeType encoding); + + Orthanc::ImageAccessor* Decode(); + + static void Encode(std::string& encoded, + const Orthanc::ImageAccessor& decoded, + Orthanc::MimeType encoding); + + // This semaphore is used to implement throttling for the + // decoding/encoding of tiles + static void InitializeTranscoderSemaphore(unsigned int maxThreads); + + static void FinalizeTranscoderSemaphore(); + }; +} diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/mirador.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/mirador.html Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,27 @@ + + + + + + + Mirador + + + + +
    + + + + diff -r b5b9719ef1c0 -r a43c2e6abce6 ViewerPlugin/openseadragon.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ViewerPlugin/openseadragon.html Wed Jul 12 21:23:14 2023 +0200 @@ -0,0 +1,32 @@ + + + + + + OpenSeadragon + + + + + + +
    + + +