261
|
1 /**
|
|
2 * Orthanc - A Lightweight, RESTful DICOM Store
|
|
3 * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
|
|
4 * Department, University Hospital of Liege, Belgium
|
|
5 * Copyright (C) 2017-2023 Osimis S.A., Belgium
|
|
6 * Copyright (C) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
|
|
7 *
|
|
8 * This program is free software: you can redistribute it and/or
|
|
9 * modify it under the terms of the GNU Affero General Public License
|
|
10 * as published by the Free Software Foundation, either version 3 of
|
|
11 * the License, or (at your option) any later version.
|
|
12 *
|
|
13 * This program is distributed in the hope that it will be useful, but
|
|
14 * WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
16 * Affero General Public License for more details.
|
|
17 *
|
|
18 * You should have received a copy of the GNU Affero General Public License
|
|
19 * along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
20 **/
|
|
21
|
|
22
|
|
23 #include "../Framework/PrecompiledHeadersWSI.h"
|
|
24 #include "IIIF.h"
|
|
25
|
|
26 #include "DicomPyramidCache.h"
|
|
27 #include "RawTile.h"
|
|
28 #include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h"
|
|
29
|
|
30 #include <Images/Image.h>
|
|
31 #include <Images/ImageProcessing.h>
|
|
32 #include <Logging.h>
|
|
33
|
|
34 #include <boost/regex.hpp>
|
|
35 #include <boost/math/special_functions/round.hpp>
|
|
36
|
|
37
|
|
38 static std::string iiifPublicUrl_;
|
|
39
|
|
40
|
|
41 static void ServeIIIFImageInfo(OrthancPluginRestOutput* output,
|
|
42 const char* url,
|
|
43 const OrthancPluginHttpRequest* request)
|
|
44 {
|
|
45 std::string seriesId(request->groups[0]);
|
|
46
|
|
47 LOG(INFO) << "IIIF: Image API call to whole-slide pyramid of series " << seriesId;
|
|
48
|
|
49 OrthancWSI::DicomPyramidCache::Locker locker(seriesId);
|
|
50
|
|
51 if (locker.GetPyramid().GetLevelCount() == 0)
|
|
52 {
|
|
53 throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
|
|
54 }
|
|
55
|
|
56 if (locker.GetPyramid().GetTileWidth(0) != locker.GetPyramid().GetTileHeight(0))
|
|
57 {
|
|
58 throw Orthanc::OrthancException(Orthanc::ErrorCode_IncompatibleImageFormat,
|
|
59 "IIIF doesn't support non-isotropic tile sizes");
|
|
60 }
|
|
61
|
|
62 for (unsigned int i = 1; i < locker.GetPyramid().GetLevelCount(); i++)
|
|
63 {
|
|
64 if (locker.GetPyramid().GetTileWidth(i) != locker.GetPyramid().GetTileWidth(0) ||
|
|
65 locker.GetPyramid().GetTileHeight(i) != locker.GetPyramid().GetTileHeight(0))
|
|
66 {
|
|
67 throw Orthanc::OrthancException(Orthanc::ErrorCode_IncompatibleImageFormat,
|
|
68 "IIIF doesn't support levels with varying tile sizes");
|
|
69 }
|
|
70 }
|
|
71
|
|
72 Json::Value sizes = Json::arrayValue;
|
|
73 Json::Value scaleFactors = Json::arrayValue;
|
|
74
|
|
75 for (unsigned int i = locker.GetPyramid().GetLevelCount(); i > 0; i--)
|
|
76 {
|
|
77 /**
|
|
78 * Openseadragon seems to have difficulties in rendering
|
|
79 * non-integer scale factors. Consequently, we only keep the
|
|
80 * levels with an integer scale factor.
|
|
81 **/
|
|
82 if (locker.GetPyramid().GetLevelWidth(0) % locker.GetPyramid().GetLevelWidth(i - 1) == 0 &&
|
|
83 locker.GetPyramid().GetLevelHeight(0) % locker.GetPyramid().GetLevelHeight(i - 1) == 0)
|
|
84 {
|
|
85 Json::Value level;
|
|
86 level["width"] = locker.GetPyramid().GetLevelWidth(i - 1);
|
|
87 level["height"] = locker.GetPyramid().GetLevelHeight(i - 1);
|
|
88 sizes.append(level);
|
|
89
|
|
90 scaleFactors.append(static_cast<float>(locker.GetPyramid().GetLevelWidth(0)) /
|
|
91 static_cast<float>(locker.GetPyramid().GetLevelWidth(i - 1)));
|
|
92 }
|
|
93 }
|
|
94
|
|
95 Json::Value tiles;
|
|
96 tiles["width"] = locker.GetPyramid().GetTileWidth(0);
|
|
97 tiles["height"] = locker.GetPyramid().GetTileHeight(0);
|
|
98 tiles["scaleFactors"] = scaleFactors;
|
|
99
|
|
100 Json::Value result;
|
|
101 result["@context"] = "http://iiif.io/api/image/2/context.json";
|
|
102 result["@id"] = iiifPublicUrl_ + seriesId;
|
|
103 result["profile"] = "http://iiif.io/api/image/2/level0.json";
|
|
104 result["protocol"] = "http://iiif.io/api/image";
|
|
105 result["width"] = locker.GetPyramid().GetLevelWidth(0);
|
|
106 result["height"] = locker.GetPyramid().GetLevelHeight(0);
|
|
107 result["sizes"] = sizes;
|
|
108
|
|
109 result["tiles"] = Json::arrayValue;
|
|
110 result["tiles"].append(tiles);
|
|
111
|
|
112 std::string s = result.toStyledString();
|
|
113 OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), "application/json");
|
|
114 }
|
|
115
|
|
116
|
|
117 static unsigned int GetPhysicalTileWidth(const OrthancWSI::ITiledPyramid& pyramid,
|
|
118 unsigned int level)
|
|
119 {
|
|
120 return static_cast<unsigned int>(boost::math::iround(
|
|
121 static_cast<float>(pyramid.GetTileWidth(level)) *
|
|
122 static_cast<float>(pyramid.GetLevelWidth(0)) /
|
|
123 static_cast<float>(pyramid.GetLevelWidth(level))));
|
|
124 }
|
|
125
|
|
126
|
|
127 static unsigned int GetPhysicalTileHeight(const OrthancWSI::ITiledPyramid& pyramid,
|
|
128 unsigned int level)
|
|
129 {
|
|
130 return static_cast<unsigned int>(boost::math::iround(
|
|
131 static_cast<float>(pyramid.GetTileHeight(level)) *
|
|
132 static_cast<float>(pyramid.GetLevelHeight(0)) /
|
|
133 static_cast<float>(pyramid.GetLevelHeight(level))));
|
|
134 }
|
|
135
|
|
136
|
|
137 static void ServeIIIFImageTile(OrthancPluginRestOutput* output,
|
|
138 const char* url,
|
|
139 const OrthancPluginHttpRequest* request)
|
|
140 {
|
|
141 std::string seriesId(request->groups[0]);
|
|
142 std::string region(request->groups[1]);
|
|
143 std::string size(request->groups[2]);
|
|
144 std::string rotation(request->groups[3]);
|
|
145 std::string quality(request->groups[4]);
|
|
146 std::string format(request->groups[5]);
|
|
147
|
|
148 LOG(INFO) << "IIIF: Image API call to tile of series " << seriesId << ": "
|
|
149 << "region=" << region << "; size=" << size << "; rotation="
|
|
150 << rotation << "; quality=" << quality << "; format=" << format;
|
|
151
|
|
152 if (rotation != "0")
|
|
153 {
|
|
154 throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported rotation: " + rotation);
|
|
155 }
|
|
156
|
|
157 if (quality != "default")
|
|
158 {
|
|
159 throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported quality: " + quality);
|
|
160 }
|
|
161
|
|
162 if (format != "jpg")
|
|
163 {
|
|
164 throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Unsupported format: " + format);
|
|
165 }
|
|
166
|
|
167 if (region == "full")
|
|
168 {
|
|
169 OrthancWSI::DicomPyramidCache::Locker locker(seriesId);
|
|
170
|
|
171 OrthancWSI::ITiledPyramid& pyramid = locker.GetPyramid();
|
|
172 const unsigned int level = pyramid.GetLevelCount() - 1;
|
|
173
|
|
174 Orthanc::Image full(Orthanc::PixelFormat_RGB24, pyramid.GetLevelWidth(level), pyramid.GetLevelHeight(level), false);
|
|
175 Orthanc::ImageProcessing::Set(full, 255, 255, 255, 0);
|
|
176
|
|
177 const unsigned int nx = OrthancWSI::CeilingDivision(pyramid.GetLevelWidth(level), pyramid.GetTileWidth(level));
|
|
178 const unsigned int ny = OrthancWSI::CeilingDivision(pyramid.GetLevelHeight(level), pyramid.GetTileHeight(level));
|
|
179 for (unsigned int ty = 0; ty < ny; ty++)
|
|
180 {
|
|
181 const unsigned int y = ty * pyramid.GetTileHeight(level);
|
|
182 const unsigned int height = std::min(pyramid.GetTileHeight(level), full.GetHeight() - y);
|
|
183
|
|
184 for (unsigned int tx = 0; tx < nx; tx++)
|
|
185 {
|
|
186 const unsigned int x = tx * pyramid.GetTileWidth(level);
|
|
187 std::unique_ptr<Orthanc::ImageAccessor> tile(pyramid.DecodeTile(level, tx, ty));
|
|
188
|
|
189 const unsigned int width = std::min(pyramid.GetTileWidth(level), full.GetWidth() - x);
|
|
190
|
|
191 Orthanc::ImageAccessor source, target;
|
|
192 tile->GetRegion(source, 0, 0, width, height);
|
|
193 full.GetRegion(target, x, y, width, height);
|
|
194
|
|
195 Orthanc::ImageProcessing::Copy(target, source);
|
|
196 }
|
|
197 }
|
|
198
|
|
199 std::string encoded;
|
|
200 OrthancWSI::RawTile::Encode(encoded, full, Orthanc::MimeType_Jpeg);
|
|
201
|
|
202 OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, encoded.c_str(),
|
|
203 encoded.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg));
|
|
204 }
|
|
205 else
|
|
206 {
|
|
207 int regionX, regionY, regionWidth, regionHeight;
|
|
208
|
|
209 bool ok = false;
|
|
210 boost::regex regionPattern("([0-9]+),([0-9]+),([0-9]+),([0-9]+)");
|
|
211 boost::cmatch regionWhat;
|
|
212 if (regex_match(region.c_str(), regionWhat, regionPattern))
|
|
213 {
|
|
214 try
|
|
215 {
|
|
216 regionX = boost::lexical_cast<int>(regionWhat[1]);
|
|
217 regionY = boost::lexical_cast<int>(regionWhat[2]);
|
|
218 regionWidth = boost::lexical_cast<int>(regionWhat[3]);
|
|
219 regionHeight = boost::lexical_cast<int>(regionWhat[4]);
|
|
220 ok = (regionX >= 0 &&
|
|
221 regionY >= 0 &&
|
|
222 regionWidth > 0 &&
|
|
223 regionHeight > 0);
|
|
224 }
|
|
225 catch (boost::bad_lexical_cast&)
|
|
226 {
|
|
227 }
|
|
228 }
|
|
229
|
|
230 if (!ok)
|
|
231 {
|
|
232 throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Not a (x,y,width,height) region: " + region);
|
|
233 }
|
|
234
|
|
235 int cropWidth;
|
|
236 boost::regex sizePattern("([0-9]+),");
|
|
237 boost::cmatch sizeWhat;
|
|
238 if (regex_match(size.c_str(), sizeWhat, sizePattern))
|
|
239 {
|
|
240 try
|
|
241 {
|
|
242 cropWidth = boost::lexical_cast<int>(sizeWhat[1]);
|
|
243 ok = (cropWidth > 0);
|
|
244 }
|
|
245 catch (boost::bad_lexical_cast&)
|
|
246 {
|
|
247 }
|
|
248 }
|
|
249
|
|
250 if (!ok)
|
|
251 {
|
|
252 throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "IIIF - Not a (width,) size: " + size);
|
|
253 }
|
|
254
|
|
255 std::unique_ptr<OrthancWSI::RawTile> rawTile;
|
|
256 std::unique_ptr<Orthanc::ImageAccessor> toCrop;
|
|
257
|
|
258 {
|
|
259 OrthancWSI::DicomPyramidCache::Locker locker(seriesId);
|
|
260
|
|
261 OrthancWSI::ITiledPyramid& pyramid = locker.GetPyramid();
|
|
262
|
|
263 unsigned int level;
|
|
264 for (level = 0; level < pyramid.GetLevelCount(); level++)
|
|
265 {
|
|
266 const unsigned int physicalTileWidth = GetPhysicalTileWidth(pyramid, level);
|
|
267 const unsigned int physicalTileHeight = GetPhysicalTileHeight(pyramid, level);
|
|
268
|
|
269 if (regionX % physicalTileWidth == 0 &&
|
|
270 regionY % physicalTileHeight == 0 &&
|
|
271 regionWidth <= physicalTileWidth &&
|
|
272 regionHeight <= physicalTileHeight &&
|
|
273 regionX + regionWidth <= pyramid.GetLevelWidth(0) &&
|
|
274 regionY + regionHeight <= pyramid.GetLevelHeight(0))
|
|
275 {
|
|
276 break;
|
|
277 }
|
|
278 }
|
|
279
|
|
280 if (level == pyramid.GetLevelCount())
|
|
281 {
|
|
282 throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "IIIF - Cannot locate the level of interest");
|
|
283 }
|
|
284 else if (cropWidth > pyramid.GetTileWidth(level))
|
|
285 {
|
|
286 throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "IIIF - Request for a cropping that is too large for the tile size");
|
|
287 }
|
|
288 else
|
|
289 {
|
|
290 rawTile.reset(new OrthancWSI::RawTile(locker.GetPyramid(), level,
|
|
291 regionX / GetPhysicalTileWidth(pyramid, level),
|
|
292 regionY / GetPhysicalTileHeight(pyramid, level)));
|
|
293
|
|
294 if (cropWidth < pyramid.GetTileWidth(level))
|
|
295 {
|
|
296 toCrop.reset(rawTile->Decode());
|
|
297 rawTile.reset(NULL);
|
|
298 }
|
|
299 }
|
|
300 }
|
|
301
|
|
302 if (rawTile.get() != NULL)
|
|
303 {
|
|
304 assert(toCrop.get() == NULL);
|
|
305
|
|
306 // Level 0 Compliance of IIIF expects JPEG files
|
|
307 rawTile->Answer(output, Orthanc::MimeType_Jpeg);
|
|
308 }
|
|
309 else if (toCrop.get() != NULL)
|
|
310 {
|
|
311 assert(rawTile.get() == NULL);
|
|
312 assert(cropWidth < toCrop->GetWidth());
|
|
313
|
|
314 Orthanc::ImageAccessor cropped;
|
|
315 toCrop->GetRegion(cropped, 0, 0, cropWidth, toCrop->GetHeight());
|
|
316
|
|
317 std::string encoded;
|
|
318 OrthancWSI::RawTile::Encode(encoded, cropped, Orthanc::MimeType_Jpeg);
|
|
319
|
|
320 OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, encoded.c_str(),
|
|
321 encoded.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg));
|
|
322 }
|
|
323 else
|
|
324 {
|
|
325 throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
|
|
326 }
|
|
327 }
|
|
328 }
|
|
329
|
|
330
|
|
331 static void ServeIIIFManifest(OrthancPluginRestOutput* output,
|
|
332 const char* url,
|
|
333 const OrthancPluginHttpRequest* request)
|
|
334 {
|
|
335 /**
|
|
336 * This is based on IIIF cookbook: "Support Deep Viewing with Basic
|
|
337 * Use of a IIIF Image Service."
|
|
338 * https://iiif.io/api/cookbook/recipe/0005-image-service/
|
|
339 **/
|
|
340
|
|
341 std::string seriesId(request->groups[0]);
|
|
342
|
|
343 LOG(INFO) << "IIIF: Presentation API call to whole-slide pyramid of series " << seriesId;
|
|
344
|
|
345 Json::Value study, series;
|
|
346 if (!OrthancPlugins::RestApiGet(series, "/series/" + seriesId, false) ||
|
|
347 !OrthancPlugins::RestApiGet(study, "/series/" + seriesId + "/study", false))
|
|
348 {
|
|
349 throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
|
|
350 }
|
|
351
|
|
352 unsigned int width, height;
|
|
353
|
|
354 {
|
|
355 OrthancWSI::DicomPyramidCache::Locker locker(seriesId);
|
|
356 width = locker.GetPyramid().GetLevelWidth(0);
|
|
357 height = locker.GetPyramid().GetLevelHeight(0);
|
|
358 }
|
|
359
|
|
360 const std::string base = iiifPublicUrl_ + seriesId;
|
|
361
|
|
362 Json::Value service;
|
|
363 service["id"] = base;
|
|
364 service["profile"] = "level0";
|
|
365 service["type"] = "ImageService3";
|
|
366
|
|
367 Json::Value body;
|
|
368 body["id"] = base + "/full/max/0/default.jpg";
|
|
369 body["type"] = "Image";
|
|
370 body["format"] = Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg);
|
|
371 body["height"] = height;
|
|
372 body["width"] = width;
|
|
373 body["service"].append(service);
|
|
374
|
|
375 Json::Value annotation;
|
|
376 annotation["id"] = base + "/annotation/p0001-image";
|
|
377 annotation["type"] = "Annotation";
|
|
378 annotation["motivation"] = "painting";
|
|
379 annotation["body"] = body;
|
|
380 annotation["target"] = base + "/canvas/p1";
|
|
381
|
|
382 Json::Value annotationPage;
|
|
383 annotationPage["id"] = base + "/page/p1/1";
|
|
384 annotationPage["type"] = "AnnotationPage";
|
|
385 annotationPage["items"].append(annotation);
|
|
386
|
|
387 Json::Value canvas;
|
|
388 canvas["id"] = annotation["target"];
|
|
389 canvas["type"] = "Canvas";
|
|
390 canvas["width"] = width;
|
|
391 canvas["height"] = height;
|
|
392
|
|
393 Json::Value labels = Json::arrayValue;
|
|
394 labels.append(series["MainDicomTags"]["SeriesDate"].asString() + " - " +
|
|
395 series["MainDicomTags"]["SeriesDescription"].asString());
|
|
396 canvas["label"]["en"] = labels;
|
|
397
|
|
398 canvas["items"].append(annotationPage);
|
|
399
|
|
400 Json::Value manifest;
|
|
401 manifest["@context"] = "http://iiif.io/api/presentation/3/context.json";
|
|
402 manifest["id"] = base + "/manifest.json";
|
|
403 manifest["type"] = "Manifest";
|
|
404
|
|
405 labels = Json::arrayValue;
|
|
406 labels.append(study["MainDicomTags"]["StudyDate"].asString() + " - " +
|
|
407 study["MainDicomTags"]["StudyDescription"].asString());
|
|
408 manifest["label"]["en"] = labels;
|
|
409
|
|
410 manifest["items"].append(canvas);
|
|
411
|
|
412 std::string s = manifest.toStyledString();
|
|
413 OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), "application/json");
|
|
414 }
|
|
415
|
|
416
|
|
417 void InitializeIIIF(const std::string& iiifPublicUrl)
|
|
418 {
|
|
419 iiifPublicUrl_ = iiifPublicUrl;
|
|
420
|
|
421 OrthancPlugins::RegisterRestCallback<ServeIIIFImageInfo>("/wsi/iiif/([0-9a-f-]+)/info.json", true);
|
|
422 OrthancPlugins::RegisterRestCallback<ServeIIIFImageTile>("/wsi/iiif/([0-9a-f-]+)/([0-9a-z,:]+)/([0-9a-z,!:]+)/([0-9,!]+)/([a-z]+)\\.([a-z]+)", true);
|
|
423 OrthancPlugins::RegisterRestCallback<ServeIIIFManifest>("/wsi/iiif/([0-9a-f-]+)/manifest.json", true);
|
|
424 }
|